e2e/perf/measure.ts
Linked repository file from this build.
import { probe } from "./probe";
import { expect, type Page, type TestInfo, type Request } from "@playwright/test";
export type NetworkProfile = {
name: string;
latencyMs: number;
downloadMbps: number;
uploadMbps: number;
};
export const profiles: NetworkProfile[] = [
{ name: "local", latencyMs: 0, downloadMbps: 0, uploadMbps: 0 },
{ name: "internet", latencyMs: 80, downloadMbps: 10, uploadMbps: 2 },
];
type RequestRow = {
path: string;
type: string;
method: string;
status?: number;
startMs: number;
durationMs?: number;
ttfbMs?: number;
jsonBytes?: number;
wireBytes: number;
failed?: string;
serverTiming?: string;
fromCache?: boolean;
};
export async function meter(page: Page, profile: NetworkProfile) {
if (process.env.QC_PERF_NO_CACHE === "1") {
// Exercise uncached navigation without Next warming the destination first.
await page.route("**/*", (route) => {
const headers = route.request().headers();
return headers["next-router-prefetch"] ||
headers["next-router-segment-prefetch"] ||
headers.purpose === "prefetch"
? route.abort()
: route.continue();
});
}
const cdp = await page.context().newCDPSession(page);
await cdp.send("Network.enable");
const cpuRate = Number(process.env.QC_PERF_CPU_RATE ?? 1);
if (!Number.isFinite(cpuRate) || cpuRate < 1) throw new Error("QC_PERF_CPU_RATE must be >= 1");
await cdp.send("Emulation.setCPUThrottlingRate", { rate: cpuRate });
if (process.env.QC_PERF_NO_CACHE === "1")
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
if (profile.latencyMs)
await cdp.send("Network.emulateNetworkConditions", {
offline: false,
latency: profile.latencyMs,
downloadThroughput: (profile.downloadMbps * 1_000_000) / 8,
uploadThroughput: (profile.uploadMbps * 1_000_000) / 8,
});
let active = false;
let started = 0;
const rows = new Map<string, RequestRow>();
const bodies: Promise<void>[] = [];
cdp.on("Network.requestWillBeSent", (event) => {
if (!active) return;
rows.set(event.requestId, {
path: new URL(event.request.url).pathname,
method: event.request.method,
type: event.type ?? "Other",
startMs: performance.now() - started,
wireBytes: 0,
});
});
cdp.on("Network.responseReceived", (event) => {
const row = rows.get(event.requestId);
if (row) {
row.status = event.response.status;
row.fromCache = !!(event.response.fromDiskCache || event.response.fromPrefetchCache);
row.serverTiming = Object.entries(event.response.headers).find(
([name]) => name.toLowerCase() === "server-timing",
)?.[1];
row.ttfbMs = event.response.timing?.receiveHeadersStart;
}
});
cdp.on("Network.loadingFinished", (event) => {
const row = rows.get(event.requestId);
if (row) {
row.wireBytes = event.encodedDataLength;
row.durationMs = performance.now() - started - row.startMs;
}
});
cdp.on("Network.loadingFailed", (event) => {
const row = rows.get(event.requestId);
if (row) row.failed = event.errorText;
});
const apiRows: RequestRow[] = [];
const apiStarts = new Map<Request, number>();
page.on("request", (request) => {
if (active && new URL(request.url()).pathname.startsWith("/api/"))
apiStarts.set(request, performance.now() - started);
});
page.on("requestfinished", (request) => {
if (!active || !apiStarts.has(request)) return;
const timing = request.timing();
const row: RequestRow = {
path: new URL(request.url()).pathname,
method: request.method(),
type: request.resourceType(),
startMs: apiStarts.get(request)!,
durationMs: timing.responseEnd,
ttfbMs: timing.responseStart,
wireBytes: 0,
};
apiRows.push(row);
bodies.push(
(async () => {
const response = await request.response();
if (response) {
row.status = response.status();
row.jsonBytes = (await response.body()).byteLength;
}
})(),
);
});
return {
async begin(selector: string, cold: boolean, path: string) {
rows.clear();
apiRows.length = 0;
bodies.length = 0;
apiStarts.clear();
started = performance.now();
active = true;
if (cold) await page.addInitScript(probe, { selector, cold, path });
else await page.evaluate(probe, { selector, cold, path });
},
async finish(info: TestInfo, label: string, mode: string, apiPath?: string) {
let readinessError = "";
try {
await page.waitForFunction(() => window.__qcPerf?.ready != null, {}, { timeout: 30_000 });
} catch {
readinessError = "Component did not become data-ready within 30 seconds";
}
const timing = await page.evaluate((path) => {
const p = window.__qcPerf!;
const navigation = performance.getEntriesByType(
"navigation",
)[0] as PerformanceNavigationTiming;
const resources = (performance.getEntriesByType("resource") as PerformanceResourceTiming[])
.filter(
(entry) =>
new URL(entry.name).pathname === path &&
entry.startTime >= (p?.started ?? 0) &&
entry.responseEnd <= (p?.ready ?? 0),
)
.sort((a, b) => b.responseEnd - a.responseEnd);
const data = resources[0];
return {
dataRequestMs: data ? data.responseEnd - data.startTime : null,
dataTtfbMs: data ? data.responseStart - data.startTime : null,
dataDownloadMs: data ? data.responseEnd - data.responseStart : null,
dataToComponentMs: data && p?.ready != null ? p.ready - data.responseEnd : null,
componentMs: p?.ready == null ? null : p.ready - p.started,
documentTtfbMs: navigation.responseStart,
documentRequestMs: navigation.responseStart - navigation.requestStart,
documentRedirectMs: navigation.redirectEnd - navigation.redirectStart,
readyAt: p?.ready,
startedAt: p?.started,
};
}, apiPath);
// Explicit post-ready observation window catches background overfetch/prefetch.
// It is excluded from componentMs; networkidle would hide polling and add a timing floor.
await page.waitForTimeout(1_000);
const smoothness = await page.evaluate(() => {
const perf = window.__qcPerf;
const summary = perf?.summary();
perf?.cancel();
return summary;
});
active = false;
const settled = await Promise.allSettled(bodies);
const result = {
component: label,
mode,
profile,
readinessError,
repeat: info.repeatEachIndex,
origin: new URL(page.url()).origin,
browserVersion: page.context().browser()?.version(),
viewport: page.viewportSize(),
timestamp: new Date().toISOString(),
...timing,
smoothness,
cpuRate,
documentTtfbMs: mode === "cold" ? timing.documentTtfbMs : null,
documentRequestMs: mode === "cold" ? timing.documentRequestMs : null,
documentRedirectMs: mode === "cold" ? timing.documentRedirectMs : null,
cacheDisabled: process.env.QC_PERF_NO_CACHE === "1",
requests: [...rows.values()],
apis: apiRows,
wireBytes: [...rows.values()].reduce((sum, row) => sum + row.wireBytes, 0),
apiJsonBytes: apiRows.reduce((sum, row) => sum + (row.jsonBytes ?? 0), 0),
unfinishedRequests: [...rows.values()].filter(
(row) => row.durationMs == null && !row.failed,
).length,
bodyReadErrors: settled.filter((row) => row.status === "rejected").length,
};
await info.attach("performance", {
body: JSON.stringify(result, null, 2),
contentType: "application/json",
});
console.log(
`${profile.name} ${mode} ${label}: ${result.componentMs == null ? "not ready" : Math.round(result.componentMs)} ms, ${result.wireBytes} wire bytes, ${result.apiJsonBytes} API JSON bytes`,
);
expect.soft(readinessError, "component readiness").toBe("");
expect.soft(result.bodyReadErrors, "API byte measurement must succeed").toBe(0);
return result;
},
};
}