325 lines
13 KiB
TypeScript
325 lines
13 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { performance } from "node:perf_hooks";
|
|
import type { BrowserContext, Page } from "@playwright/test";
|
|
import { chromium } from "@playwright/test";
|
|
|
|
import { countDuplicateTrpcPaths, summarize, validateBrowserMeasurement } from "./dev-benchmark-lib.mts";
|
|
|
|
type BoardMeasurement = {
|
|
boardMountedMs: number;
|
|
domContentLoadedMs: number;
|
|
postIdleWarmInteractionMs: number;
|
|
widgetCount: number;
|
|
widgetsReadyMs: number;
|
|
pageErrors: string[];
|
|
responseStatus: number | null;
|
|
trpcDuplicateRequestCount: number;
|
|
trpcRequestCount: number;
|
|
webSocketCount: number;
|
|
performance: {
|
|
cls: number;
|
|
decodedScriptBytes: number;
|
|
jsHeapBytes: number | null;
|
|
lcpMs: number | null;
|
|
longTaskCount: number;
|
|
longTaskTotalMs: number;
|
|
resourceCount: number;
|
|
transferBytes: number;
|
|
};
|
|
};
|
|
|
|
const root = path.resolve(import.meta.dirname, "../..");
|
|
const argumentsSet = new Set(process.argv.slice(2));
|
|
const smokeOnly = argumentsSet.has("--smoke");
|
|
const iterations = Number(process.env.DEV_BENCHMARK_ITERATIONS ?? 10);
|
|
const spotlightIdleMs = Number(process.env.DEV_BENCHMARK_SPOTLIGHT_IDLE_MS ?? 2_500);
|
|
const outputDirectory = path.resolve(
|
|
process.env.DEV_BENCHMARK_OUTPUT_DIR ?? path.join(root, "benchmark-results", "dev"),
|
|
);
|
|
const boardUrl = process.env.DEV_BENCHMARK_BOARD_URL;
|
|
const storageStatePath = process.env.DEV_BENCHMARK_STORAGE_STATE;
|
|
const readinessUrl = process.env.DEV_BENCHMARK_READINESS_URL ?? "http://127.0.0.1:3000/api/health/ready";
|
|
const boardSelector = "[data-homarr-dev-benchmark-board]";
|
|
const interactionSelector = '[data-homarr-dev-benchmark-interaction="search"]';
|
|
const spotlightSelector = "[data-homarr-dev-benchmark-spotlight]";
|
|
const spotlightFeedbackSelector =
|
|
"[data-homarr-dev-benchmark-spotlight-feedback], [data-homarr-dev-benchmark-spotlight]";
|
|
|
|
if (!Number.isInteger(iterations) || iterations < 7) {
|
|
throw new Error("DEV_BENCHMARK_ITERATIONS must be an integer of at least 7");
|
|
}
|
|
if (!Number.isFinite(spotlightIdleMs) || spotlightIdleMs < 0) {
|
|
throw new Error("DEV_BENCHMARK_SPOTLIGHT_IDLE_MS must be non-negative");
|
|
}
|
|
|
|
await fs.mkdir(outputDirectory, { recursive: true });
|
|
|
|
if (smokeOnly) {
|
|
const measurements: number[] = [];
|
|
for (let iteration = 0; iteration < iterations; iteration++) {
|
|
const startedAt = performance.now();
|
|
const response = await fetch(readinessUrl);
|
|
await response.arrayBuffer();
|
|
if (!response.ok) throw new Error(`Readiness smoke received HTTP ${response.status}`);
|
|
measurements.push(Math.round(performance.now() - startedAt));
|
|
}
|
|
|
|
const result = {
|
|
kind: "readiness-smoke",
|
|
claimEligible: false,
|
|
reason: "Readiness checks do not exercise the browser or dashboard graph",
|
|
url: readinessUrl,
|
|
iterations,
|
|
summary: summarize(measurements),
|
|
};
|
|
const outputPath = path.join(outputDirectory, `smoke-${Date.now()}.json`);
|
|
await fs.writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`);
|
|
console.log(JSON.stringify({ outputPath, result }, null, 2));
|
|
process.exit(0);
|
|
}
|
|
|
|
if (!boardUrl || !storageStatePath) {
|
|
throw new Error(
|
|
"Authenticated mode requires DEV_BENCHMARK_BOARD_URL and DEV_BENCHMARK_STORAGE_STATE. " +
|
|
"Use --smoke only for the non-claimable readiness check.",
|
|
);
|
|
}
|
|
|
|
await fs.access(storageStatePath);
|
|
|
|
const installPerformanceObservers = () => {
|
|
const state = { cls: 0, lcpMs: null as number | null, longTaskCount: 0, longTaskTotalMs: 0 };
|
|
Object.defineProperty(window, "homarrBenchmarkVitals", { value: state, configurable: true });
|
|
|
|
if (PerformanceObserver.supportedEntryTypes.includes("largest-contentful-paint")) {
|
|
new PerformanceObserver((list) => {
|
|
const latest = list.getEntries().at(-1);
|
|
if (latest) state.lcpMs = latest.startTime;
|
|
}).observe({ type: "largest-contentful-paint", buffered: true });
|
|
}
|
|
if (PerformanceObserver.supportedEntryTypes.includes("layout-shift")) {
|
|
new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) {
|
|
const shift = entry as PerformanceEntry & { hadRecentInput?: boolean; value?: number };
|
|
if (!shift.hadRecentInput) state.cls += shift.value ?? 0;
|
|
}
|
|
}).observe({ type: "layout-shift", buffered: true });
|
|
}
|
|
if (PerformanceObserver.supportedEntryTypes.includes("longtask")) {
|
|
new PerformanceObserver((list) => {
|
|
for (const entry of list.getEntries()) {
|
|
state.longTaskCount += 1;
|
|
state.longTaskTotalMs += entry.duration;
|
|
}
|
|
}).observe({ type: "longtask", buffered: true });
|
|
}
|
|
};
|
|
|
|
const createContextAsync = async (): Promise<BrowserContext> => {
|
|
const context = await browser.newContext({ storageState: storageStatePath });
|
|
await context.addInitScript(installPerformanceObservers);
|
|
return context;
|
|
};
|
|
|
|
const browser = await chromium.launch();
|
|
const freshContext: BoardMeasurement[] = [];
|
|
const warmPage: BoardMeasurement[] = [];
|
|
const coldInteractionSamples: Array<{ feedbackMs: number; readyMs: number }> = [];
|
|
|
|
const measureBoardAsync = async (page: Page) => {
|
|
const trpcRequests: string[] = [];
|
|
const pageErrors: string[] = [];
|
|
let webSocketCount = 0;
|
|
|
|
page.on("request", (request) => {
|
|
if (request.url().includes("/api/trpc/")) trpcRequests.push(request.url());
|
|
});
|
|
page.on("websocket", () => {
|
|
webSocketCount += 1;
|
|
});
|
|
page.on("pageerror", (error) => pageErrors.push(error.message));
|
|
|
|
const startedAt = performance.now();
|
|
const response = await page.goto(boardUrl, { waitUntil: "domcontentloaded" });
|
|
const domContentLoadedMs = Math.round(performance.now() - startedAt);
|
|
await page.locator(boardSelector).waitFor({ state: "visible" });
|
|
const boardMountedMs = Math.round(performance.now() - startedAt);
|
|
|
|
await page.waitForFunction(
|
|
(selector) => {
|
|
const board = document.querySelector(selector);
|
|
if (!board) return false;
|
|
const widgetCount = board.querySelectorAll('[data-type="item"]').length;
|
|
return widgetCount > 0 && board.querySelectorAll("[data-homarr-widget-ready]").length === widgetCount;
|
|
},
|
|
boardSelector,
|
|
{ timeout: 30_000 },
|
|
);
|
|
const widgetsReadyMs = Math.round(performance.now() - startedAt);
|
|
const widgetCount = await page.locator(`${boardSelector} [data-type="item"]`).count();
|
|
|
|
await page.waitForTimeout(spotlightIdleMs);
|
|
const warmInteractionStartedAt = performance.now();
|
|
await page.locator(`${interactionSelector}:visible`).click();
|
|
await page.locator(spotlightSelector).waitFor({ state: "visible" });
|
|
const postIdleWarmInteractionMs = Math.round(performance.now() - warmInteractionStartedAt);
|
|
await page.keyboard.press("Escape");
|
|
await page.locator(spotlightSelector).waitFor({ state: "hidden" });
|
|
|
|
const browserPerformance = await page.evaluate(() => {
|
|
const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
|
|
const vitals = (
|
|
window as Window & {
|
|
homarrBenchmarkVitals?: {
|
|
cls: number;
|
|
lcpMs: number | null;
|
|
longTaskCount: number;
|
|
longTaskTotalMs: number;
|
|
};
|
|
}
|
|
).homarrBenchmarkVitals;
|
|
const memory = performance as Performance & { memory?: { usedJSHeapSize: number } };
|
|
return {
|
|
cls: Number((vitals?.cls ?? 0).toFixed(4)),
|
|
decodedScriptBytes: resources
|
|
.filter((resource) => resource.initiatorType === "script")
|
|
.reduce((total, resource) => total + resource.decodedBodySize, 0),
|
|
jsHeapBytes: memory.memory?.usedJSHeapSize ?? null,
|
|
lcpMs: vitals?.lcpMs === null || vitals?.lcpMs === undefined ? null : Math.round(vitals.lcpMs),
|
|
longTaskCount: vitals?.longTaskCount ?? 0,
|
|
longTaskTotalMs: Math.round(vitals?.longTaskTotalMs ?? 0),
|
|
resourceCount: resources.length,
|
|
transferBytes: resources.reduce((total, resource) => total + resource.transferSize, 0),
|
|
};
|
|
});
|
|
|
|
const measurement: BoardMeasurement = {
|
|
responseStatus: response?.status() ?? null,
|
|
domContentLoadedMs,
|
|
boardMountedMs,
|
|
widgetsReadyMs,
|
|
widgetCount,
|
|
postIdleWarmInteractionMs,
|
|
trpcRequestCount: trpcRequests.length,
|
|
trpcDuplicateRequestCount: countDuplicateTrpcPaths(trpcRequests),
|
|
webSocketCount,
|
|
performance: browserPerformance,
|
|
pageErrors,
|
|
};
|
|
const errors = validateBrowserMeasurement({
|
|
responseStatus: measurement.responseStatus,
|
|
domContentLoadedMs,
|
|
boardHydratedMs: boardMountedMs,
|
|
widgetsReadyMs,
|
|
widgetCount,
|
|
postIdleWarmInteractionMs,
|
|
error: pageErrors.length > 0 ? pageErrors.join("; ") : undefined,
|
|
});
|
|
if (errors.length > 0) throw new Error(errors.map((error) => `${boardUrl}: ${error}`).join("; "));
|
|
|
|
return measurement;
|
|
};
|
|
|
|
const measureColdInteractionAsync = async (page: Page) => {
|
|
const pageErrors: string[] = [];
|
|
page.on("pageerror", (error) => pageErrors.push(error.message));
|
|
|
|
const response = await page.goto(boardUrl, { waitUntil: "domcontentloaded" });
|
|
if (!response?.ok()) {
|
|
throw new Error(`Cold-interaction board returned HTTP ${String(response?.status() ?? "none")}`);
|
|
}
|
|
await page.locator(boardSelector).waitFor({ state: "visible" });
|
|
const interactionStartedAt = performance.now();
|
|
await page.locator(`${interactionSelector}:visible`).click();
|
|
await page.locator(spotlightFeedbackSelector).waitFor({ state: "visible" });
|
|
const feedbackMs = Math.round(performance.now() - interactionStartedAt);
|
|
await page.locator(spotlightSelector).waitFor({ state: "visible" });
|
|
const readyMs = Math.round(performance.now() - interactionStartedAt);
|
|
if (pageErrors.length > 0) throw new Error(`Cold-interaction page errors: ${pageErrors.join("; ")}`);
|
|
return { feedbackMs, readyMs };
|
|
};
|
|
|
|
try {
|
|
for (let iteration = 0; iteration < iterations; iteration++) {
|
|
const context = await createContextAsync();
|
|
const firstPage = await context.newPage();
|
|
freshContext.push(await measureBoardAsync(firstPage));
|
|
await firstPage.close();
|
|
const secondPage = await context.newPage();
|
|
warmPage.push(await measureBoardAsync(secondPage));
|
|
await context.close();
|
|
|
|
const coldContext = await createContextAsync();
|
|
await coldContext.addInitScript(() => {
|
|
// Measure the actual first-open path instead of racing the intentional
|
|
// requestIdleCallback prefetch used during normal idle time.
|
|
Object.defineProperty(window, "requestIdleCallback", { value: () => 0, configurable: true });
|
|
Object.defineProperty(window, "cancelIdleCallback", { value: () => undefined, configurable: true });
|
|
});
|
|
const coldPage = await coldContext.newPage();
|
|
coldInteractionSamples.push(await measureColdInteractionAsync(coldPage));
|
|
await coldContext.close();
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
|
|
const summarizeMeasurements = (measurements: BoardMeasurement[]) => ({
|
|
boardMountedMs: summarize(measurements.map(({ boardMountedMs }) => boardMountedMs)),
|
|
widgetsReadyMs: summarize(measurements.map(({ widgetsReadyMs }) => widgetsReadyMs)),
|
|
domContentLoadedMs: summarize(measurements.map(({ domContentLoadedMs }) => domContentLoadedMs)),
|
|
postIdleWarmInteractionMs: summarize(measurements.map(({ postIdleWarmInteractionMs }) => postIdleWarmInteractionMs)),
|
|
lcpMs: summarize(measurements.map(({ performance: browserPerformance }) => browserPerformance.lcpMs)),
|
|
cls: summarize(measurements.map(({ performance: browserPerformance }) => browserPerformance.cls)),
|
|
longTaskTotalMs: summarize(
|
|
measurements.map(({ performance: browserPerformance }) => browserPerformance.longTaskTotalMs),
|
|
),
|
|
decodedScriptBytes: summarize(
|
|
measurements.map(({ performance: browserPerformance }) => browserPerformance.decodedScriptBytes),
|
|
),
|
|
transferBytes: summarize(measurements.map(({ performance: browserPerformance }) => browserPerformance.transferBytes)),
|
|
jsHeapBytes: summarize(measurements.map(({ performance: browserPerformance }) => browserPerformance.jsHeapBytes)),
|
|
trpcRequests: summarize(measurements.map(({ trpcRequestCount }) => trpcRequestCount)),
|
|
duplicateTrpcRequests: summarize(measurements.map(({ trpcDuplicateRequestCount }) => trpcDuplicateRequestCount)),
|
|
});
|
|
|
|
const sha = process.env.DEV_BENCHMARK_SHA ?? null;
|
|
const image = process.env.DEV_BENCHMARK_IMAGE ?? null;
|
|
const runtime = process.env.DEV_BENCHMARK_RUNTIME ?? "development";
|
|
const claimEligible = false;
|
|
const result = {
|
|
kind: "authenticated-board",
|
|
claimEligible,
|
|
claimIneligibleReasons: [
|
|
"served revision and production runtime are self-reported, not independently verified",
|
|
...(runtime === "production" ? [] : ["runtime is not production"]),
|
|
...(sha ? [] : ["DEV_BENCHMARK_SHA is missing"]),
|
|
...(image ? [] : ["DEV_BENCHMARK_IMAGE is missing"]),
|
|
],
|
|
boardUrl,
|
|
createdAt: new Date().toISOString(),
|
|
environment: {
|
|
architecture: process.arch,
|
|
cpus: os.cpus().map(({ model }) => model),
|
|
image,
|
|
node: process.version,
|
|
platform: `${process.platform} ${os.release()}`,
|
|
runtime,
|
|
sha,
|
|
},
|
|
iterations,
|
|
spotlightIdleMs,
|
|
measurements: { freshContext, warmPage, coldInteractionSamples },
|
|
summary: {
|
|
coldInteractionFeedbackMs: summarize(coldInteractionSamples.map(({ feedbackMs }) => feedbackMs)),
|
|
coldInteractionReadyMs: summarize(coldInteractionSamples.map(({ readyMs }) => readyMs)),
|
|
freshContext: summarizeMeasurements(freshContext),
|
|
warmPage: summarizeMeasurements(warmPage),
|
|
},
|
|
};
|
|
const outputPath = path.join(outputDirectory, `board-${Date.now()}.json`);
|
|
await fs.writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`);
|
|
console.log(JSON.stringify({ outputPath, claimEligible, summary: result.summary }, null, 2));
|