import { cpSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { spawn } from "node:child_process"; import { chromium, devices } from "@playwright/test"; const root = process.cwd(); const externalBaseUrl = process.env.PERF_BASE_URL; const localEnvPath = resolve(root, ".env.local"); const baseUrl = externalBaseUrl ?? "http://127.0.0.1:3100"; const routes = parseList( process.env.PERF_ROUTES ?? "/splash,/tip", ); const runCount = readPositiveInteger(process.env.PERF_RUNS, 3); const outputPath = resolve( root, process.env.PERF_REPORT_PATH ?? ".next/diagnostics/web-vitals-baseline.json", ); const shouldThrottle = process.env.PERF_DISABLE_THROTTLING !== "1"; let serverProcess; try { if (!externalBaseUrl) { const buildIdPath = resolve(root, ".next/BUILD_ID"); if (!existsSync(buildIdPath)) { throw new Error( "Production build not found. Run `pnpm build` before `pnpm perf:vitals`.", ); } serverProcess = startProductionServer(); await waitForServer(baseUrl); } const browser = await chromium.launch({ headless: true }); const samples = []; try { for (const route of routes) { for (let run = 1; run <= runCount; run += 1) { const sample = await measureRoute(browser, route, run); samples.push(sample); console.log( `${route} run ${run}/${runCount}: LCP ${formatMetric(sample.lcpMs, "ms")}, CLS ${formatMetric(sample.cls, "")}`, ); } } } finally { await browser.close(); } const routeReports = routes.map((route) => summarizeRoute(route, samples)); const report = { generatedAt: new Date().toISOString(), baseUrl, profile: { device: "Pixel 7", runs: runCount, network: shouldThrottle ? "1.6 Mbps down / 750 Kbps up / 150 ms latency" : "unthrottled", cpuSlowdownMultiplier: shouldThrottle ? 4 : 1, }, routes: routeReports, samples, }; mkdirSync(dirname(outputPath), { recursive: true }); writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`); console.table( routeReports.map((route) => ({ route: route.route, finalPath: route.finalPaths.join(", "), TTFB: formatMetric(route.median.ttfbMs, " ms"), FCP: formatMetric(route.median.fcpMs, " ms"), LCP: formatMetric(route.median.lcpMs, " ms"), CLS: formatDecimal(route.median.cls, 3), "synthetic INP": formatMetric(route.median.syntheticInpMs, " ms"), "initial JS": formatBytes(route.median.initialJsTransferBytes), })), ); console.log(`\nWeb Vitals baseline written to ${outputPath}`); } finally { serverProcess?.kill("SIGTERM"); } async function measureRoute(browser, route, run) { const context = await browser.newContext({ ...devices["Pixel 7"], serviceWorkers: "block", }); const page = await context.newPage(); const session = await context.newCDPSession(page); if (shouldThrottle) { await session.send("Network.enable"); await session.send("Network.emulateNetworkConditions", { offline: false, latency: 150, downloadThroughput: (1.6 * 1024 * 1024) / 8, uploadThroughput: (750 * 1024) / 8, }); await session.send("Emulation.setCPUThrottlingRate", { rate: 4 }); } await page.addInitScript(installPerformanceObservers); try { await page.goto(new URL(route, baseUrl).toString(), { waitUntil: "load", timeout: 45_000, }); await page.waitForTimeout(2_500); await page.mouse.click(1, 1); await page.waitForTimeout(500); return await page.evaluate( ({ requestedRoute, sampleRun }) => { const state = window.__cozsweetPerformanceBaseline; const navigation = performance.getEntriesByType("navigation")[0]; const fcp = performance .getEntriesByType("paint") .find((entry) => entry.name === "first-contentful-paint"); const scriptResources = performance .getEntriesByType("resource") .filter( (entry) => entry.initiatorType === "script" && new URL(entry.name).pathname.includes("/_next/"), ); const interactions = [...state.interactions.values()].sort( (left, right) => right - left, ); const inpIndex = Math.min( interactions.length - 1, Math.floor(interactions.length / 50), ); return { route: requestedRoute, run: sampleRun, finalPath: window.location.pathname, ttfbMs: navigation ? navigation.responseStart - navigation.requestStart : null, fcpMs: fcp?.startTime ?? null, lcpMs: state.lcp, cls: state.cls, syntheticInpMs: inpIndex >= 0 ? interactions[inpIndex] : null, initialJsTransferBytes: scriptResources.reduce( (total, entry) => total + entry.transferSize, 0, ), initialJsEncodedBytes: scriptResources.reduce( (total, entry) => total + entry.encodedBodySize, 0, ), }; }, { requestedRoute: route, sampleRun: run }, ); } finally { await context.close(); } } function installPerformanceObservers() { const state = { lcp: null, cls: 0, clsSessionValue: 0, clsSessionStart: 0, clsSessionEnd: 0, interactions: new Map(), }; window.__cozsweetPerformanceBaseline = state; try { new PerformanceObserver((list) => { const entries = list.getEntries(); state.lcp = entries.at(-1)?.startTime ?? state.lcp; }).observe({ type: "largest-contentful-paint", buffered: true }); } catch {} try { new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (entry.hadRecentInput) continue; const startsNewSession = state.clsSessionEnd === 0 || entry.startTime - state.clsSessionEnd >= 1_000 || entry.startTime - state.clsSessionStart >= 5_000; if (startsNewSession) { state.clsSessionValue = entry.value; state.clsSessionStart = entry.startTime; } else { state.clsSessionValue += entry.value; } state.clsSessionEnd = entry.startTime; state.cls = Math.max(state.cls, state.clsSessionValue); } }).observe({ type: "layout-shift", buffered: true }); } catch {} try { new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (!entry.interactionId) continue; state.interactions.set( entry.interactionId, Math.max( state.interactions.get(entry.interactionId) ?? 0, entry.duration, ), ); } }).observe({ type: "event", buffered: true, durationThreshold: 16 }); } catch {} } function summarizeRoute(route, samples) { const routeSamples = samples.filter((sample) => sample.route === route); const metricNames = [ "ttfbMs", "fcpMs", "lcpMs", "cls", "syntheticInpMs", "initialJsTransferBytes", "initialJsEncodedBytes", ]; const median = Object.fromEntries( metricNames.map((name) => [ name, getMedian(routeSamples.map((sample) => sample[name])), ]), ); return { route, finalPaths: [...new Set(routeSamples.map((sample) => sample.finalPath))], median, }; } function getMedian(values) { const numbers = values .filter((value) => typeof value === "number" && Number.isFinite(value)) .sort((left, right) => left - right); if (numbers.length === 0) return null; const middle = Math.floor(numbers.length / 2); return numbers.length % 2 === 0 ? (numbers[middle - 1] + numbers[middle]) / 2 : numbers[middle]; } function startProductionServer() { const standaloneRoot = resolve(root, ".next/standalone"); const standaloneServer = resolve(standaloneRoot, "server.js"); const standaloneNextDirectory = resolve(standaloneRoot, ".next"); mkdirSync(standaloneNextDirectory, { recursive: true }); cpSync(resolve(root, "public"), resolve(standaloneRoot, "public"), { recursive: true, force: true, }); cpSync( resolve(root, ".next/static"), resolve(standaloneNextDirectory, "static"), { recursive: true, force: true }, ); const child = spawn( process.execPath, [ ...(existsSync(localEnvPath) ? [`--env-file=${localEnvPath}`] : []), standaloneServer, ], { cwd: standaloneRoot, env: { ...process.env, HOSTNAME: "127.0.0.1", PORT: "3100", }, stdio: ["ignore", "pipe", "pipe"], }, ); child.stdout.on("data", (chunk) => process.stdout.write(chunk)); child.stderr.on("data", (chunk) => process.stderr.write(chunk)); return child; } async function waitForServer(url) { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { try { const response = await fetch(url, { redirect: "manual" }); if (response.status < 500) return; } catch {} await new Promise((resolvePromise) => setTimeout(resolvePromise, 250)); } throw new Error(`Timed out waiting for ${url}`); } function parseList(value) { return value .split(",") .map((item) => item.trim()) .filter(Boolean) .map((route) => (route.startsWith("/") ? route : `/${route}`)); } function readPositiveInteger(value, fallback) { const number = Number.parseInt(value ?? "", 10); return Number.isInteger(number) && number > 0 ? number : fallback; } function formatMetric(value, suffix) { return typeof value === "number" ? `${value.toFixed(1)}${suffix}` : "n/a"; } function formatDecimal(value, fractionDigits) { return typeof value === "number" ? value.toFixed(fractionDigits) : "n/a"; } function formatBytes(value) { return typeof value === "number" ? `${(value / 1024).toFixed(1)} KiB` : "n/a"; }