794 lines
25 KiB
TypeScript
794 lines
25 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* Comprehensive Page Load Performance Testing Suite
|
|
*
|
|
* Measures:
|
|
* - First Contentful Paint (FCP)
|
|
* - Largest Contentful Paint (LCP)
|
|
* - Time to Interactive (TTI)
|
|
* - Total Blocking Time (TBT)
|
|
* - Cumulative Layout Shift (CLS)
|
|
* - First Input Delay (FID)
|
|
* - Interaction to Next Paint (INP)
|
|
* - Network requests and bundle sizes
|
|
* - JavaScript execution time
|
|
*/
|
|
|
|
import { chromium, type Browser, type Page } from "playwright";
|
|
import { writeFileSync } from "fs";
|
|
|
|
interface PageTestConfig {
|
|
name: string;
|
|
path: string;
|
|
}
|
|
|
|
interface PerformanceMetrics {
|
|
fcp: number;
|
|
lcp: number;
|
|
cls: number;
|
|
fid: number;
|
|
inp: 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;
|
|
runs: PerformanceMetrics[];
|
|
average: PerformanceMetrics;
|
|
median: PerformanceMetrics;
|
|
p95: PerformanceMetrics;
|
|
min: PerformanceMetrics;
|
|
max: PerformanceMetrics;
|
|
}
|
|
|
|
const BASE_URL = process.env.TEST_URL || "http://localhost:3000";
|
|
const RUNS_PER_PAGE = parseInt(process.env.RUNS || "5", 10);
|
|
const WARMUP_RUNS = 1;
|
|
|
|
// Pages to test
|
|
const TEST_PAGES: PageTestConfig[] = [
|
|
{ name: "Home", path: "/" },
|
|
{ name: "About", path: "/about" },
|
|
{ name: "Blog Index", path: "/blog" },
|
|
{ name: "Blog Post (basic)", path: "/blog/I_made_a_macOS_app_in_a_day" },
|
|
{
|
|
name: "Blog Post with multiple images",
|
|
path: "/blog/Shapes_With_Abigail!"
|
|
},
|
|
{ name: "Blog Post with large banner", path: "/blog/My_MacOS_rice." },
|
|
{ name: "Blog Post with Mermaid", path: "/blog/A_Journey_in_Self_Hosting" },
|
|
{ name: "Resume", path: "/resume" },
|
|
{ name: "Contact", path: "/contact" },
|
|
{ name: "Login", path: "/login" },
|
|
{ name: "404", path: "/404" }
|
|
];
|
|
|
|
// Add additional blog post path if provided
|
|
if (process.env.TEST_BLOG_POST) {
|
|
TEST_PAGES.push({
|
|
name: "Custom Blog Post",
|
|
path: process.env.TEST_BLOG_POST
|
|
});
|
|
}
|
|
|
|
async function setupPerformanceObservers(page: Page) {
|
|
await page.addInitScript(() => {
|
|
(window as any).__perfMetrics = {
|
|
lcp: 0,
|
|
cls: 0,
|
|
fid: 0,
|
|
inp: 0,
|
|
largestContentfulPaint: 0,
|
|
cumulativeLayoutShift: 0,
|
|
firstInputDelay: 0,
|
|
interactionToNextPaint: 0,
|
|
layoutShifts: [] as number[],
|
|
longTasks: [] as number[],
|
|
interactions: [] as number[]
|
|
};
|
|
|
|
// Observe LCP
|
|
if ("PerformanceObserver" in window) {
|
|
try {
|
|
const lcpObserver = new PerformanceObserver((entryList) => {
|
|
const entries = entryList.getEntries();
|
|
const lastEntry = entries[entries.length - 1] as any;
|
|
(window as any).__perfMetrics.lcp =
|
|
lastEntry.renderTime || lastEntry.loadTime;
|
|
(window as any).__perfMetrics.largestContentfulPaint =
|
|
lastEntry.renderTime || lastEntry.loadTime;
|
|
});
|
|
lcpObserver.observe({
|
|
type: "largest-contentful-paint",
|
|
buffered: true
|
|
});
|
|
} catch (e) {
|
|
// LCP not supported
|
|
}
|
|
|
|
// Observe CLS
|
|
try {
|
|
const clsObserver = new PerformanceObserver((entryList) => {
|
|
for (const entry of entryList.getEntries()) {
|
|
const layoutShift = entry as any;
|
|
if (!layoutShift.hadRecentInput) {
|
|
(window as any).__perfMetrics.cls += layoutShift.value;
|
|
(window as any).__perfMetrics.cumulativeLayoutShift +=
|
|
layoutShift.value;
|
|
(window as any).__perfMetrics.layoutShifts.push(
|
|
layoutShift.value
|
|
);
|
|
}
|
|
}
|
|
});
|
|
clsObserver.observe({ type: "layout-shift", buffered: true });
|
|
} catch (e) {
|
|
// CLS not supported
|
|
}
|
|
|
|
// Observe FID (first input)
|
|
try {
|
|
const fidObserver = new PerformanceObserver((entryList) => {
|
|
const firstInput = entryList.getEntries()[0] as any;
|
|
if (firstInput) {
|
|
(window as any).__perfMetrics.fid =
|
|
firstInput.processingStart - firstInput.startTime;
|
|
(window as any).__perfMetrics.firstInputDelay =
|
|
firstInput.processingStart - firstInput.startTime;
|
|
}
|
|
});
|
|
fidObserver.observe({ type: "first-input", buffered: true });
|
|
} catch (e) {
|
|
// FID not supported
|
|
}
|
|
|
|
// Observe long tasks
|
|
try {
|
|
const longTaskObserver = new PerformanceObserver((entryList) => {
|
|
for (const entry of entryList.getEntries()) {
|
|
(window as any).__perfMetrics.longTasks.push(entry.duration);
|
|
}
|
|
});
|
|
longTaskObserver.observe({ type: "longtask", buffered: true });
|
|
} catch (e) {
|
|
// Long tasks not supported
|
|
}
|
|
|
|
// Observe INP (event timing for interactions)
|
|
try {
|
|
const inpObserver = new PerformanceObserver((entryList) => {
|
|
for (const entry of entryList.getEntries()) {
|
|
const eventEntry = entry as any;
|
|
if (eventEntry.interactionId) {
|
|
const interactionLatency = eventEntry.duration;
|
|
(window as any).__perfMetrics.interactions.push(
|
|
interactionLatency
|
|
);
|
|
// INP is the worst (98th percentile) interaction latency
|
|
const sortedInteractions = [
|
|
...(window as any).__perfMetrics.interactions
|
|
].sort((a: number, b: number) => b - a);
|
|
const p98Index = Math.floor(sortedInteractions.length * 0.02);
|
|
(window as any).__perfMetrics.inp =
|
|
sortedInteractions[p98Index] || sortedInteractions[0] || 0;
|
|
(window as any).__perfMetrics.interactionToNextPaint = (
|
|
window as any
|
|
).__perfMetrics.inp;
|
|
}
|
|
}
|
|
});
|
|
inpObserver.observe({ type: "event", buffered: true });
|
|
} catch (e) {
|
|
// Event timing not supported
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function collectPerformanceMetrics(
|
|
page: Page
|
|
): Promise<PerformanceMetrics> {
|
|
// Wait for page to be loaded
|
|
await page.waitForLoadState("load");
|
|
|
|
// Wait a bit longer for LCP to settle (it can change as content loads)
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Additional wait for any remaining network activity
|
|
await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {
|
|
// Ignore timeout - networkidle may never happen for some pages
|
|
});
|
|
|
|
// Collect comprehensive performance metrics
|
|
const metrics = await page.evaluate(() => {
|
|
const perf = performance.getEntriesByType(
|
|
"navigation"
|
|
)[0] as PerformanceNavigationTiming;
|
|
const paint = performance.getEntriesByType("paint");
|
|
const fcp = paint.find((entry) => entry.name === "first-contentful-paint");
|
|
|
|
// Get metrics from our observers
|
|
const observedMetrics = (window as any).__perfMetrics || {
|
|
lcp: 0,
|
|
cls: 0,
|
|
fid: 0,
|
|
inp: 0,
|
|
longTasks: [],
|
|
interactions: []
|
|
};
|
|
|
|
// Fallback to direct API if observers didn't capture anything
|
|
let lcp = observedMetrics.lcp;
|
|
let cls = observedMetrics.cls;
|
|
let fid = observedMetrics.fid;
|
|
let inp = observedMetrics.inp;
|
|
|
|
if (lcp === 0) {
|
|
const lcpEntries = performance.getEntriesByType(
|
|
"largest-contentful-paint"
|
|
) as any[];
|
|
if (lcpEntries.length > 0) {
|
|
lcp =
|
|
lcpEntries[lcpEntries.length - 1].renderTime ||
|
|
lcpEntries[lcpEntries.length - 1].loadTime;
|
|
}
|
|
}
|
|
|
|
if (cls === 0) {
|
|
const layoutShiftEntries = performance.getEntriesByType(
|
|
"layout-shift"
|
|
) as any[];
|
|
cls = layoutShiftEntries
|
|
.filter((entry: any) => !entry.hadRecentInput)
|
|
.reduce((sum: number, entry: any) => sum + entry.value, 0);
|
|
}
|
|
|
|
// Calculate INP from event timing entries if not already captured
|
|
if (inp === 0) {
|
|
const eventEntries = performance.getEntriesByType("event") as any[];
|
|
const interactionLatencies = eventEntries
|
|
.filter((entry: any) => entry.interactionId)
|
|
.map((entry: any) => entry.duration);
|
|
|
|
if (interactionLatencies.length > 0) {
|
|
// INP is the 98th percentile of interaction latencies
|
|
const sorted = interactionLatencies.sort(
|
|
(a: number, b: number) => b - a
|
|
);
|
|
const p98Index = Math.floor(sorted.length * 0.02);
|
|
inp = sorted[p98Index] || sorted[0];
|
|
}
|
|
}
|
|
|
|
// Get resource timing
|
|
const resources = performance.getEntriesByType(
|
|
"resource"
|
|
) as PerformanceResourceTiming[];
|
|
|
|
let totalBytes = 0;
|
|
let jsBytes = 0;
|
|
let cssBytes = 0;
|
|
let imageBytes = 0;
|
|
let fontBytes = 0;
|
|
let jsRequests = 0;
|
|
let cssRequests = 0;
|
|
let imageRequests = 0;
|
|
|
|
resources.forEach((resource) => {
|
|
const size = resource.transferSize || resource.encodedBodySize || 0;
|
|
totalBytes += size;
|
|
|
|
const isJS =
|
|
resource.name.includes(".js") ||
|
|
resource.name.includes("/_build/") ||
|
|
resource.initiatorType === "script";
|
|
const isCSS =
|
|
resource.name.includes(".css") || resource.initiatorType === "css";
|
|
const isImage =
|
|
resource.initiatorType === "img" ||
|
|
/\.(jpg|jpeg|png|gif|svg|webp|avif)/.test(resource.name);
|
|
const isFont = /\.(woff|woff2|ttf|otf|eot)/.test(resource.name);
|
|
|
|
if (isJS) {
|
|
jsBytes += size;
|
|
jsRequests++;
|
|
} else if (isCSS) {
|
|
cssBytes += size;
|
|
cssRequests++;
|
|
} else if (isImage) {
|
|
imageBytes += size;
|
|
imageRequests++;
|
|
} else if (isFont) {
|
|
fontBytes += size;
|
|
}
|
|
});
|
|
|
|
// Calculate long task duration
|
|
let taskDuration = 0;
|
|
if (observedMetrics.longTasks && observedMetrics.longTasks.length > 0) {
|
|
taskDuration = observedMetrics.longTasks.reduce(
|
|
(sum: number, duration: number) => sum + duration,
|
|
0
|
|
);
|
|
}
|
|
|
|
// Get more granular performance entries
|
|
let jsExecutionTime = 0;
|
|
let layoutDuration = 0;
|
|
let paintDuration = 0;
|
|
|
|
const measures = performance.getEntriesByType("measure");
|
|
measures.forEach((entry) => {
|
|
if (entry.name.includes("script") || entry.name.includes("js")) {
|
|
jsExecutionTime += entry.duration;
|
|
}
|
|
});
|
|
|
|
// Check for script evaluation entries
|
|
const entries = performance.getEntries();
|
|
entries.forEach((entry: any) => {
|
|
if (entry.entryType === "measure") {
|
|
if (
|
|
entry.name.toLowerCase().includes("script") ||
|
|
entry.name.toLowerCase().includes("js")
|
|
) {
|
|
jsExecutionTime += entry.duration;
|
|
} else if (entry.name.toLowerCase().includes("layout")) {
|
|
layoutDuration += entry.duration;
|
|
} else if (
|
|
entry.name.toLowerCase().includes("paint") ||
|
|
entry.name.toLowerCase().includes("render")
|
|
) {
|
|
paintDuration += entry.duration;
|
|
}
|
|
}
|
|
});
|
|
|
|
return {
|
|
fcp: fcp?.startTime || 0,
|
|
lcp,
|
|
cls,
|
|
fid,
|
|
inp,
|
|
ttfb: perf.responseStart - perf.requestStart,
|
|
domContentLoaded: perf.domContentLoadedEventEnd - perf.fetchStart,
|
|
loadComplete: perf.loadEventEnd - perf.fetchStart,
|
|
totalRequests: resources.length,
|
|
totalBytes,
|
|
jsBytes,
|
|
cssBytes,
|
|
imageBytes,
|
|
fontBytes,
|
|
jsRequests,
|
|
cssRequests,
|
|
imageRequests,
|
|
jsExecutionTime,
|
|
taskDuration,
|
|
layoutDuration,
|
|
paintDuration
|
|
};
|
|
});
|
|
|
|
return metrics;
|
|
}
|
|
|
|
async function testPagePerformance(
|
|
browser: Browser,
|
|
pageConfig: PageTestConfig
|
|
): Promise<TestResult> {
|
|
const url = `${BASE_URL}${pageConfig.path}`;
|
|
const runs: PerformanceMetrics[] = [];
|
|
|
|
console.log(`\n📊 Testing: ${pageConfig.name} (${url})`);
|
|
console.log(
|
|
` Running ${WARMUP_RUNS} warmup + ${RUNS_PER_PAGE} measured runs...\n`
|
|
);
|
|
|
|
// Warmup runs (not counted)
|
|
for (let i = 0; i < WARMUP_RUNS; i++) {
|
|
const context = await browser.newContext();
|
|
const page = await context.newPage();
|
|
await setupPerformanceObservers(page);
|
|
await page.goto(url, { waitUntil: "load" });
|
|
await page.close();
|
|
await context.close();
|
|
console.log(` ✓ Warmup run ${i + 1}/${WARMUP_RUNS}`);
|
|
}
|
|
|
|
// Measured runs
|
|
for (let i = 0; i < RUNS_PER_PAGE; i++) {
|
|
console.log(` → Run ${i + 1}/${RUNS_PER_PAGE}...`);
|
|
|
|
// Create new context for each run to ensure clean state
|
|
const context = await browser.newContext({
|
|
viewport: { width: 1920, height: 1080 }
|
|
});
|
|
const page = await context.newPage();
|
|
|
|
// Setup performance observers before navigation
|
|
await setupPerformanceObservers(page);
|
|
|
|
// Navigate and collect metrics
|
|
await page.goto(url, { waitUntil: "load" });
|
|
const metrics = await collectPerformanceMetrics(page);
|
|
|
|
await page.close();
|
|
await context.close();
|
|
|
|
runs.push(metrics);
|
|
console.log(
|
|
` FCP: ${metrics.fcp.toFixed(0)}ms | LCP: ${metrics.lcp.toFixed(0)}ms | CLS: ${metrics.cls.toFixed(3)} | Requests: ${metrics.totalRequests}`
|
|
);
|
|
}
|
|
|
|
// Calculate statistics
|
|
const average = calculateAverage(runs);
|
|
const median = calculateMedian(runs);
|
|
const p95 = calculatePercentile(runs, 95);
|
|
const min = calculateMin(runs);
|
|
const max = calculateMax(runs);
|
|
|
|
return {
|
|
page: pageConfig.name,
|
|
url,
|
|
runs,
|
|
average,
|
|
median,
|
|
p95,
|
|
min,
|
|
max
|
|
};
|
|
}
|
|
|
|
function calculateAverage(runs: PerformanceMetrics[]): PerformanceMetrics {
|
|
const sum = runs.reduce((acc, run) => {
|
|
Object.keys(run).forEach((key) => {
|
|
acc[key] = (acc[key] || 0) + run[key as keyof PerformanceMetrics];
|
|
});
|
|
return acc;
|
|
}, {} as any);
|
|
|
|
Object.keys(sum).forEach((key) => {
|
|
sum[key] /= runs.length;
|
|
});
|
|
|
|
return sum;
|
|
}
|
|
|
|
function calculateMedian(runs: PerformanceMetrics[]): PerformanceMetrics {
|
|
const sorted = runs.slice().sort((a, b) => a.lcp - b.lcp);
|
|
const mid = Math.floor(sorted.length / 2);
|
|
return sorted[mid];
|
|
}
|
|
|
|
function calculatePercentile(
|
|
runs: PerformanceMetrics[],
|
|
percentile: number
|
|
): PerformanceMetrics {
|
|
const sorted = runs.slice().sort((a, b) => a.lcp - b.lcp);
|
|
const index = Math.ceil((percentile / 100) * sorted.length) - 1;
|
|
return sorted[index];
|
|
}
|
|
|
|
function calculateMin(runs: PerformanceMetrics[]): PerformanceMetrics {
|
|
return runs.reduce(
|
|
(min, run) => {
|
|
const result: any = {};
|
|
Object.keys(run).forEach((key) => {
|
|
const k = key as keyof PerformanceMetrics;
|
|
result[k] = Math.min(min[k], run[k]);
|
|
});
|
|
return result;
|
|
},
|
|
{ ...runs[0] }
|
|
);
|
|
}
|
|
|
|
function calculateMax(runs: PerformanceMetrics[]): PerformanceMetrics {
|
|
return runs.reduce(
|
|
(max, run) => {
|
|
const result: any = {};
|
|
Object.keys(run).forEach((key) => {
|
|
const k = key as keyof PerformanceMetrics;
|
|
result[k] = Math.max(max[k], run[k]);
|
|
});
|
|
return result;
|
|
},
|
|
{ ...runs[0] }
|
|
);
|
|
}
|
|
|
|
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 getWebVitalRating(
|
|
metric: "lcp" | "fcp" | "cls" | "fid" | "inp",
|
|
value: number
|
|
): string {
|
|
const thresholds = {
|
|
lcp: { good: 2500, needsImprovement: 4000 },
|
|
fcp: { good: 1800, needsImprovement: 3000 },
|
|
cls: { good: 0.1, needsImprovement: 0.25 },
|
|
fid: { good: 100, needsImprovement: 300 },
|
|
inp: { good: 200, needsImprovement: 500 }
|
|
};
|
|
|
|
const t = thresholds[metric];
|
|
if (value <= t.good) return "🟢 Good";
|
|
if (value <= t.needsImprovement) return "🟡 Needs Improvement";
|
|
return "🔴 Poor";
|
|
}
|
|
|
|
function printResults(results: TestResult[]) {
|
|
console.log("\n\n");
|
|
console.log(
|
|
"═══════════════════════════════════════════════════════════════════"
|
|
);
|
|
console.log(
|
|
" PERFORMANCE TEST RESULTS "
|
|
);
|
|
console.log(
|
|
"═══════════════════════════════════════════════════════════════════"
|
|
);
|
|
console.log(`Base URL: ${BASE_URL}`);
|
|
console.log(`Runs per page: ${RUNS_PER_PAGE}`);
|
|
console.log(`Date: ${new Date().toLocaleString()}`);
|
|
console.log(
|
|
"───────────────────────────────────────────────────────────────────\n"
|
|
);
|
|
|
|
results.forEach((result) => {
|
|
console.log(`\n📄 ${result.page} - ${result.url}`);
|
|
console.log("─".repeat(70));
|
|
|
|
console.log("\n Core Web Vitals (Median | Min → Max):");
|
|
console.log(
|
|
` LCP (Largest Contentful Paint): ${formatTime(result.median.lcp).padEnd(8)} | ${formatTime(result.min.lcp)} → ${formatTime(result.max.lcp)} ${getWebVitalRating("lcp", result.median.lcp)}`
|
|
);
|
|
console.log(
|
|
` FCP (First Contentful Paint): ${formatTime(result.median.fcp).padEnd(8)} | ${formatTime(result.min.fcp)} → ${formatTime(result.max.fcp)} ${getWebVitalRating("fcp", result.median.fcp)}`
|
|
);
|
|
console.log(
|
|
` CLS (Cumulative Layout Shift): ${result.median.cls.toFixed(3).padEnd(8)} | ${result.min.cls.toFixed(3)} → ${result.max.cls.toFixed(3)} ${getWebVitalRating("cls", result.median.cls)}`
|
|
);
|
|
console.log(
|
|
` INP (Interaction to Next Paint): ${formatTime(result.median.inp).padEnd(8)} | ${formatTime(result.min.inp)} → ${formatTime(result.max.inp)} ${getWebVitalRating("inp", result.median.inp)}`
|
|
);
|
|
|
|
console.log("\n Loading Metrics (Median):");
|
|
console.log(
|
|
` TTFB (Time to First Byte): ${formatTime(result.median.ttfb)}`
|
|
);
|
|
console.log(
|
|
` DOM Content Loaded: ${formatTime(result.median.domContentLoaded)}`
|
|
);
|
|
console.log(
|
|
` Load Complete: ${formatTime(result.median.loadComplete)}`
|
|
);
|
|
|
|
console.log("\n Resource Loading (Median):");
|
|
console.log(
|
|
` Total Requests: ${result.median.totalRequests.toFixed(0)}`
|
|
);
|
|
console.log(
|
|
` Total Transfer Size: ${formatBytes(result.median.totalBytes)}`
|
|
);
|
|
console.log(
|
|
` ├─ JavaScript (${result.median.jsRequests.toFixed(0)} req): ${formatBytes(result.median.jsBytes)}`
|
|
);
|
|
console.log(
|
|
` ├─ CSS (${result.median.cssRequests.toFixed(0)} req): ${formatBytes(result.median.cssBytes)}`
|
|
);
|
|
console.log(
|
|
` ├─ Images (${result.median.imageRequests.toFixed(0)} req): ${formatBytes(result.median.imageBytes)}`
|
|
);
|
|
console.log(
|
|
` └─ Fonts: ${formatBytes(result.median.fontBytes)}`
|
|
);
|
|
|
|
if (result.median.jsExecutionTime > 0) {
|
|
console.log("\n Performance Details (Median):");
|
|
console.log(
|
|
` JS Execution Time: ${formatTime(result.median.jsExecutionTime)}`
|
|
);
|
|
if (result.median.taskDuration > 0) {
|
|
console.log(
|
|
` Long Task Duration: ${formatTime(result.median.taskDuration)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
console.log("\n Variability (Standard Deviation):");
|
|
const lcpStdDev = Math.sqrt(
|
|
result.runs.reduce(
|
|
(sum, run) => sum + Math.pow(run.lcp - result.average.lcp, 2),
|
|
0
|
|
) / result.runs.length
|
|
);
|
|
const fcpStdDev = Math.sqrt(
|
|
result.runs.reduce(
|
|
(sum, run) => sum + Math.pow(run.fcp - result.average.fcp, 2),
|
|
0
|
|
) / result.runs.length
|
|
);
|
|
console.log(` LCP: ±${formatTime(lcpStdDev)}`);
|
|
console.log(` FCP: ±${formatTime(fcpStdDev)}`);
|
|
});
|
|
|
|
console.log(
|
|
"\n\n═══════════════════════════════════════════════════════════════════"
|
|
);
|
|
console.log(
|
|
" SUMMARY "
|
|
);
|
|
console.log(
|
|
"═══════════════════════════════════════════════════════════════════\n"
|
|
);
|
|
|
|
// Overall averages
|
|
const overallAverage = {
|
|
lcp: results.reduce((sum, r) => sum + r.median.lcp, 0) / results.length,
|
|
fcp: results.reduce((sum, r) => sum + r.median.fcp, 0) / results.length,
|
|
cls: results.reduce((sum, r) => sum + r.median.cls, 0) / results.length,
|
|
inp: results.reduce((sum, r) => sum + r.median.inp, 0) / results.length,
|
|
ttfb: results.reduce((sum, r) => sum + r.median.ttfb, 0) / results.length,
|
|
totalBytes:
|
|
results.reduce((sum, r) => sum + r.median.totalBytes, 0) / results.length,
|
|
jsBytes:
|
|
results.reduce((sum, r) => sum + r.median.jsBytes, 0) / results.length,
|
|
totalRequests:
|
|
results.reduce((sum, r) => sum + r.median.totalRequests, 0) /
|
|
results.length
|
|
};
|
|
|
|
console.log(" Overall Averages (Median across all pages):");
|
|
console.log(` LCP: ${formatTime(overallAverage.lcp)}`);
|
|
console.log(` FCP: ${formatTime(overallAverage.fcp)}`);
|
|
console.log(` CLS: ${overallAverage.cls.toFixed(3)}`);
|
|
console.log(` INP: ${formatTime(overallAverage.inp)}`);
|
|
console.log(` TTFB: ${formatTime(overallAverage.ttfb)}`);
|
|
console.log(
|
|
` Total Size: ${formatBytes(overallAverage.totalBytes)}`
|
|
);
|
|
console.log(` JS Size: ${formatBytes(overallAverage.jsBytes)}`);
|
|
console.log(
|
|
` Total Requests: ${overallAverage.totalRequests.toFixed(0)}`
|
|
);
|
|
|
|
console.log("\n Page Rankings (by LCP):");
|
|
const sortedResults = [...results].sort(
|
|
(a, b) => a.median.lcp - b.median.lcp
|
|
);
|
|
sortedResults.forEach((result, index) => {
|
|
const rating =
|
|
result.median.lcp <= 2500
|
|
? "🟢"
|
|
: result.median.lcp <= 4000
|
|
? "🟡"
|
|
: "🔴";
|
|
console.log(
|
|
` ${index + 1}. ${rating} ${result.page.padEnd(20)} ${formatTime(result.median.lcp)}`
|
|
);
|
|
});
|
|
|
|
console.log("\n Optimization Opportunities:");
|
|
|
|
// Find pages with highest JS bytes
|
|
const highestJS = [...results].sort(
|
|
(a, b) => b.median.jsBytes - a.median.jsBytes
|
|
)[0];
|
|
if (highestJS.median.jsBytes > 500 * 1024) {
|
|
// > 500KB
|
|
console.log(
|
|
` 📦 ${highestJS.page}: High JS bundle (${formatBytes(highestJS.median.jsBytes)}) - consider code splitting`
|
|
);
|
|
}
|
|
|
|
// Find pages with slow LCP
|
|
const slowLCP = results.filter((r) => r.median.lcp > 2500);
|
|
if (slowLCP.length > 0) {
|
|
console.log(
|
|
` 🐌 ${slowLCP.length} page(s) with LCP > 2.5s - optimize largest content element`
|
|
);
|
|
}
|
|
|
|
// Find pages with high CLS
|
|
const highCLS = results.filter((r) => r.median.cls > 0.1);
|
|
if (highCLS.length > 0) {
|
|
console.log(
|
|
` 📐 ${highCLS.length} page(s) with CLS > 0.1 - add size attributes to images/elements`
|
|
);
|
|
}
|
|
|
|
// Find pages with high INP
|
|
const highINP = results.filter((r) => r.median.inp > 200);
|
|
if (highINP.length > 0) {
|
|
console.log(
|
|
` ⚡ ${highINP.length} page(s) with INP > 200ms - optimize event handlers and reduce long tasks`
|
|
);
|
|
}
|
|
|
|
console.log("\n");
|
|
}
|
|
|
|
async function main() {
|
|
console.log("🚀 Starting Performance Testing Suite...\n");
|
|
console.log(`Target: ${BASE_URL}`);
|
|
console.log(`Pages to test: ${TEST_PAGES.length}`);
|
|
console.log(`Runs per page: ${RUNS_PER_PAGE} (+ ${WARMUP_RUNS} warmup)\n`);
|
|
|
|
// Check if server is running
|
|
try {
|
|
const response = await fetch(BASE_URL);
|
|
if (!response.ok) {
|
|
throw new Error(`Server returned ${response.status}`);
|
|
}
|
|
console.log("✅ Server is reachable\n");
|
|
} catch (error) {
|
|
console.error(`❌ Error: Cannot connect to ${BASE_URL}`);
|
|
console.error(" Make sure the dev server is running with: bun run dev");
|
|
process.exit(1);
|
|
}
|
|
|
|
const browser = await chromium.launch({
|
|
headless: true,
|
|
args: ["--disable-dev-shm-usage"]
|
|
});
|
|
const results: TestResult[] = [];
|
|
|
|
for (const pageConfig of TEST_PAGES) {
|
|
try {
|
|
const result = await testPagePerformance(browser, pageConfig);
|
|
results.push(result);
|
|
} catch (error) {
|
|
console.error(`❌ Error testing ${pageConfig.name}:`, error);
|
|
}
|
|
}
|
|
|
|
await browser.close();
|
|
|
|
// Print results
|
|
printResults(results);
|
|
|
|
// Save results to JSON file
|
|
const timestamp = new Date()
|
|
.toISOString()
|
|
.replace(/[:.]/g, "-")
|
|
.split("T")[0];
|
|
const filename = `perf-results-${timestamp}.json`;
|
|
const output = {
|
|
timestamp: new Date().toISOString(),
|
|
baseUrl: BASE_URL,
|
|
runsPerPage: RUNS_PER_PAGE,
|
|
results
|
|
};
|
|
writeFileSync(filename, JSON.stringify(output, null, 2));
|
|
console.log(`📁 Detailed results saved to: ${filename}\n`);
|
|
}
|
|
|
|
main().catch(console.error);
|