126 lines
7.3 KiB
JavaScript
126 lines
7.3 KiB
JavaScript
import { build, preview } from "vite";
|
|
import { chromium } from "@playwright/test";
|
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
import { resolve, dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import os from "node:os";
|
|
import { execFileSync } from "node:child_process";
|
|
import { parseArgs } from "node:util";
|
|
import { benchmarkPlugin } from "./benchmark-plugin.mjs";
|
|
import { installBrowserFixture, seedDrawingScene, measureTabChange } from "./browser-fixture.mjs";
|
|
|
|
const frontend = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const { values } = parseArgs({ options: {
|
|
output: { type: "string", default: process.env.ARTCRAFT_PERF_DIR || "/tmp/artcraft-performance" },
|
|
runs: { type: "string", default: process.env.ARTCRAFT_PERF_RUNS || "7" },
|
|
"skip-build": { type: "boolean", default: !!process.env.ARTCRAFT_PERF_SKIP_BUILD },
|
|
"build-only": { type: "boolean", default: false },
|
|
channel: { type: "string", default: "chrome" },
|
|
"baseline-ref": { type: "string" },
|
|
"smoke-imports": { type: "boolean", default: false },
|
|
} });
|
|
const output = resolve(values.output);
|
|
const runs = Number(values.runs);
|
|
if (!Number.isInteger(runs) || runs < 1) throw new Error("--runs must be a positive integer");
|
|
const configFile = resolve(frontend, "apps/artcraft/vite.config.ts");
|
|
// Match `npm run build` in the app: Tailwind resolves its config/content from cwd.
|
|
process.chdir(dirname(configFile));
|
|
await mkdir(output, { recursive: true });
|
|
if (!values["skip-build"]) {
|
|
await build({ configFile, plugins: [benchmarkPlugin({ baselineRef: values["baseline-ref"], frontend })], build: { outDir: resolve(output, "dist"), manifest: true } });
|
|
await writeFile(resolve(output, "build-info.json"), JSON.stringify({
|
|
builtAt: new Date().toISOString(),
|
|
revision: execFileSync("git", ["rev-parse", "HEAD"], { cwd: frontend, encoding: "utf8" }).trim(),
|
|
baselineOverlay: values["baseline-ref"] || null,
|
|
changedFiles: execFileSync("git", ["diff", "--name-only"], { cwd: frontend, encoding: "utf8" }).trim().split("\n"),
|
|
}, null, 2));
|
|
}
|
|
if (values["build-only"]) process.exit(0);
|
|
const server = await preview({ configFile, build: { outDir: resolve(output, "dist") }, preview: { host: "127.0.0.1", port: 0, strictPort: false } });
|
|
let browser;
|
|
try {
|
|
browser = await chromium.launch({ channel: values.channel, headless: true });
|
|
const url = server.resolvedUrls.local[0];
|
|
const samples = [];
|
|
const errors = [];
|
|
const importedPages = [];
|
|
for (let run = 0; run < runs; run++) {
|
|
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, deviceScaleFactor: 1 });
|
|
await context.addInitScript(installBrowserFixture);
|
|
const page = await context.newPage();
|
|
page.on("pageerror", (error) => { errors.push(error.message); console.error("Browser error:", error.message); });
|
|
// No analytics, external media, or authenticated API traffic in this fixture.
|
|
await context.route("**/*", (route) => route.request().url().startsWith(url) || route.request().url().startsWith("blob:")
|
|
? route.continue() : route.abort());
|
|
const cdp = await context.newCDPSession(page);
|
|
await cdp.send("Network.enable");
|
|
await cdp.send("Network.setCacheDisabled", { cacheDisabled: true });
|
|
await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
await page.getByRole("heading", { name: "What will you craft today?" }).waitFor();
|
|
await page.waitForFunction(() => !document.querySelector("video"));
|
|
if (!await page.evaluate(() => getComputedStyle(document.querySelector(".fixed")).position === "fixed")) {
|
|
throw new Error("Tailwind styles are missing; benchmark layout is invalid");
|
|
}
|
|
const collectStartup = async () => page.evaluate(async () => {
|
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
return {
|
|
readyMs: performance.now(),
|
|
jsBytes: performance.getEntriesByType("resource").filter((r) => r.name.endsWith(".js")).reduce((sum, r) => sum + r.decodedBodySize, 0),
|
|
longTaskMs: window.__ARTCRAFT_COUNTERS__.longTasks.reduce((sum, t) => sum + t.duration, 0),
|
|
webglContexts: window.__ARTCRAFT_COUNTERS__.webglContexts,
|
|
};
|
|
});
|
|
const startup = await collectStartup();
|
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
await page.getByRole("heading", { name: "What will you craft today?" }).waitFor();
|
|
await page.waitForFunction(() => !document.querySelector("video"));
|
|
const refresh = await collectStartup();
|
|
const fixture = await page.evaluate(seedDrawingScene);
|
|
const firstDraw = await page.evaluate(measureTabChange, "2D");
|
|
// Allow image decoding and the canvas bake debounce to settle outside timing.
|
|
await page.waitForTimeout(1000);
|
|
const leaveDraw = await page.evaluate(measureTabChange, "APPS");
|
|
const returnDraw = await page.evaluate(measureTabChange, "2D");
|
|
await page.waitForTimeout(1000);
|
|
const afterReturn = await page.evaluate(() => ({
|
|
nodeCount: window.__ARTCRAFT_BENCH__.scene.getState().drawNodes.length,
|
|
selectedCount: window.__ARTCRAFT_BENCH__.scene.getState().selectedNodeIds.length,
|
|
historyLength: window.__ARTCRAFT_BENCH__.scene.getState().history.length,
|
|
commands: window.__ARTCRAFT_COUNTERS__.commands,
|
|
}));
|
|
if (run === 0) await page.screenshot({ path: resolve(output, "drawing.png") });
|
|
samples.push({ startup, refresh, firstDraw, leaveDraw, returnDraw, fixture, afterReturn });
|
|
console.log(JSON.stringify({ run: run + 1, startup, refresh, firstDraw, leaveDraw, returnDraw }));
|
|
// Validate deferred module evaluation separately from all timed operations.
|
|
if (values["smoke-imports"] && run === runs - 1) {
|
|
const manifest = JSON.parse(await readFile(resolve(output, "dist/.vite/manifest.json"), "utf8"));
|
|
for (const [source, chunk] of Object.entries(manifest)) {
|
|
if (source.startsWith("src/pages/") && chunk.isDynamicEntry) {
|
|
await page.evaluate(async (file) => { await import(/* @vite-ignore */ `/${file}`); }, chunk.file);
|
|
importedPages.push(source);
|
|
}
|
|
}
|
|
}
|
|
await context.close();
|
|
}
|
|
const summary = {};
|
|
for (const event of ["startup", "refresh", "firstDraw", "leaveDraw", "returnDraw"]) {
|
|
summary[event] = {};
|
|
for (const metric of Object.keys(samples[0][event])) {
|
|
const values = samples.map((s) => s[event][metric]).sort((a, b) => a - b);
|
|
summary[event][metric] = { median: values[Math.floor(values.length / 2)], min: values[0], max: values.at(-1) };
|
|
}
|
|
}
|
|
const result = {
|
|
timestamp: new Date().toISOString(), build: JSON.parse(await readFile(resolve(output, "build-info.json"), "utf8")),
|
|
environment: { platform: os.platform(), arch: os.arch(), cpu: os.cpus()[0].model, browser: browser.version(), node: process.version, runs, viewport: "1440x1000@1", ipc: "mocked", network: "local assets only, browser cache disabled" },
|
|
summary, samples, importedPages, errors: [...new Set(errors)],
|
|
};
|
|
await writeFile(resolve(output, "results.json"), `${JSON.stringify(result, null, 2)}\n`);
|
|
console.log(JSON.stringify({ summary, errors: result.errors, output }, null, 2));
|
|
if (errors.length) throw new Error("Browser errors invalidate this run; see results.json");
|
|
} finally {
|
|
await browser?.close();
|
|
await new Promise((resolve, reject) => server.httpServer.close((error) => error ? reject(error) : resolve()));
|
|
}
|