perf(ui): enforce build performance budgets (#106367)

This commit is contained in:
Peter Steinberger
2026-07-13 05:07:18 -07:00
committed by GitHub
parent aa98780675
commit 644bc79ed1
4 changed files with 398 additions and 1 deletions
+1 -1
View File
@@ -1990,7 +1990,7 @@
"tui:pty:test:watch:all": "node --import tsx scripts/dev/tui-pty-test-watch.ts --mode all",
"tui:pty:test:watch:fake": "node --import tsx scripts/dev/tui-pty-test-watch.ts --mode fake",
"tui:pty:test:watch:local": "node --import tsx scripts/dev/tui-pty-test-watch.ts --mode local",
"ui:build": "node scripts/ui.js build && node scripts/check-control-ui-precompressed-assets.mjs",
"ui:build": "node scripts/ui.js build && node scripts/check-control-ui-precompressed-assets.mjs && node scripts/check-control-ui-performance.mjs",
"ui:dev": "node scripts/ui.js dev",
"ui:i18n:check": "node --import tsx scripts/control-ui-i18n.ts check",
"ui:i18n:report": "node --import tsx scripts/control-ui-i18n-report.ts",
@@ -0,0 +1,63 @@
export type ControlUiAssetMetrics = {
file: string;
type: "js" | "css";
rawBytes: number;
gzipBytes: number;
brotliBytes: number;
};
export type ControlUiAssetSummary = {
requests: number;
rawBytes: number;
gzipBytes: number;
brotliBytes: number;
};
export type ControlUiPerformanceMetrics = {
schemaVersion: 1;
startup: {
js: ControlUiAssetSummary;
css: ControlUiAssetSummary;
assets: ControlUiAssetMetrics[];
};
total: { js: ControlUiAssetSummary; css: ControlUiAssetSummary };
largest: { js: ControlUiAssetMetrics; css: ControlUiAssetMetrics };
};
export type ControlUiPerformanceBudgets = {
startupJsRequests: number;
startupCssRequests: number;
startupJsGzipBytes: number;
startupCssGzipBytes: number;
largestJsGzipBytes: number;
largestCssGzipBytes: number;
};
export type ControlUiPerformanceBudgetViolation = {
metric: string;
actual: number;
limit: number;
unit: "count" | "bytes";
};
export const CONTROL_UI_PERFORMANCE_BUDGETS: Readonly<ControlUiPerformanceBudgets>;
export function extractControlUiStartupAssetPaths(html: string): string[];
export function collectControlUiPerformanceMetrics(distDir: string): ControlUiPerformanceMetrics;
export function evaluateControlUiPerformanceBudgets(
metrics: ControlUiPerformanceMetrics,
budgets?: Readonly<ControlUiPerformanceBudgets>,
): ControlUiPerformanceBudgetViolation[];
export function formatControlUiPerformanceBytes(bytes: number): string;
export function formatControlUiPerformanceReport(
metrics: ControlUiPerformanceMetrics,
budgets?: Readonly<ControlUiPerformanceBudgets>,
): string;
export function runControlUiPerformanceCheck(
distDir: string,
budgets?: Readonly<ControlUiPerformanceBudgets>,
): {
metrics: ControlUiPerformanceMetrics;
budgets: Readonly<ControlUiPerformanceBudgets>;
violations: ControlUiPerformanceBudgetViolation[];
report: string;
};
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env node
// Reports and enforces compressed Control UI asset budgets after a production build.
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const KIB = 1024;
// Small, explicit headroom over the optimized baseline. Budget changes should
// accompany an intentional loading or chunking decision.
export const CONTROL_UI_PERFORMANCE_BUDGETS = Object.freeze({
startupJsRequests: 24,
startupCssRequests: 1,
startupJsGzipBytes: 320 * KIB,
startupCssGzipBytes: 42 * KIB,
largestJsGzipBytes: 205 * KIB,
largestCssGzipBytes: 42 * KIB,
});
function controlUiAssetPathFromUrl(value) {
const normalized = value.split(/[?#]/u, 1)[0]?.replace(/\\/gu, "/") ?? "";
const markerIndex = normalized.lastIndexOf("assets/");
if (markerIndex === -1) {
return null;
}
const assetPath = normalized.slice(markerIndex);
if (assetPath.includes("../") || !/\.(?:css|js)$/u.test(assetPath)) {
return null;
}
return assetPath;
}
export function extractControlUiStartupAssetPaths(html) {
const assets = new Set();
for (const tag of html.matchAll(/<(?:link|script)\b[^>]*>/giu)) {
const attribute = tag[0].match(/\s(?:href|src)\s*=\s*["']([^"']+)["']/iu);
const assetPath = attribute ? controlUiAssetPathFromUrl(attribute[1]) : null;
if (assetPath) {
assets.add(assetPath);
}
}
return [...assets].toSorted();
}
function readAssetMetrics(assetsDir, entry) {
const file = `assets/${entry.name}`;
const sourcePath = path.join(assetsDir, entry.name);
const gzipPath = `${sourcePath}.gz`;
const brotliPath = `${sourcePath}.br`;
for (const sidecarPath of [gzipPath, brotliPath]) {
if (!fs.existsSync(sidecarPath)) {
throw new Error(`Control UI performance check missing ${path.basename(sidecarPath)}`);
}
}
return {
file,
type: entry.name.endsWith(".js") ? "js" : "css",
rawBytes: fs.statSync(sourcePath).size,
gzipBytes: fs.statSync(gzipPath).size,
brotliBytes: fs.statSync(brotliPath).size,
};
}
function summarizeAssets(assets) {
return assets.reduce(
(summary, asset) => ({
requests: summary.requests + 1,
rawBytes: summary.rawBytes + asset.rawBytes,
gzipBytes: summary.gzipBytes + asset.gzipBytes,
brotliBytes: summary.brotliBytes + asset.brotliBytes,
}),
{ requests: 0, rawBytes: 0, gzipBytes: 0, brotliBytes: 0 },
);
}
function largestAsset(assets) {
return assets.toSorted(
(left, right) => right.gzipBytes - left.gzipBytes || left.file.localeCompare(right.file),
)[0];
}
export function collectControlUiPerformanceMetrics(distDir) {
const assetsDir = path.join(distDir, "assets");
const html = fs.readFileSync(path.join(distDir, "index.html"), "utf8");
const assets = fs
.readdirSync(assetsDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && /\.(?:css|js)$/u.test(entry.name))
.map((entry) => readAssetMetrics(assetsDir, entry));
const assetsByFile = new Map(assets.map((asset) => [asset.file, asset]));
const startup = extractControlUiStartupAssetPaths(html).map((file) => {
const asset = assetsByFile.get(file);
if (!asset) {
throw new Error(`Control UI performance check cannot find startup asset ${file}`);
}
return asset;
});
const jsAssets = assets.filter((asset) => asset.type === "js");
const cssAssets = assets.filter((asset) => asset.type === "css");
if (jsAssets.length === 0 || cssAssets.length === 0 || startup.length === 0) {
throw new Error("Control UI performance check found an incomplete production bundle");
}
return {
schemaVersion: 1,
startup: {
js: summarizeAssets(startup.filter((asset) => asset.type === "js")),
css: summarizeAssets(startup.filter((asset) => asset.type === "css")),
assets: startup,
},
total: {
js: summarizeAssets(jsAssets),
css: summarizeAssets(cssAssets),
},
largest: {
js: largestAsset(jsAssets),
css: largestAsset(cssAssets),
},
};
}
export function evaluateControlUiPerformanceBudgets(
metrics,
budgets = CONTROL_UI_PERFORMANCE_BUDGETS,
) {
const checks = [
["startup JS requests", metrics.startup.js.requests, budgets.startupJsRequests, "count"],
["startup CSS requests", metrics.startup.css.requests, budgets.startupCssRequests, "count"],
["startup JS gzip", metrics.startup.js.gzipBytes, budgets.startupJsGzipBytes, "bytes"],
["startup CSS gzip", metrics.startup.css.gzipBytes, budgets.startupCssGzipBytes, "bytes"],
["largest JS gzip", metrics.largest.js.gzipBytes, budgets.largestJsGzipBytes, "bytes"],
["largest CSS gzip", metrics.largest.css.gzipBytes, budgets.largestCssGzipBytes, "bytes"],
];
return checks.flatMap(([metric, actual, limit, unit]) =>
actual > limit ? [{ metric, actual, limit, unit }] : [],
);
}
export function formatControlUiPerformanceBytes(bytes) {
return bytes < KIB ? `${bytes} B` : `${(bytes / KIB).toFixed(1)} KiB`;
}
function formatRequestCount(count) {
return `${count} ${count === 1 ? "request" : "requests"}`;
}
function formatAssetSummary(summary) {
return `${formatRequestCount(summary.requests)}, ${formatControlUiPerformanceBytes(summary.gzipBytes)} gzip, ${formatControlUiPerformanceBytes(summary.brotliBytes)} br`;
}
function formatViolation(violation) {
const actual =
violation.unit === "bytes"
? formatControlUiPerformanceBytes(violation.actual)
: String(violation.actual);
const limit =
violation.unit === "bytes"
? formatControlUiPerformanceBytes(violation.limit)
: String(violation.limit);
return `${violation.metric}: ${actual} exceeds ${limit}`;
}
export function formatControlUiPerformanceReport(
metrics,
budgets = CONTROL_UI_PERFORMANCE_BUDGETS,
) {
const violations = evaluateControlUiPerformanceBudgets(metrics, budgets);
const lines = [
"Control UI performance:",
` startup JS: ${formatAssetSummary(metrics.startup.js)} (limits: ${formatRequestCount(budgets.startupJsRequests)}, ${formatControlUiPerformanceBytes(budgets.startupJsGzipBytes)} gzip)`,
` startup CSS: ${formatAssetSummary(metrics.startup.css)} (limits: ${formatRequestCount(budgets.startupCssRequests)}, ${formatControlUiPerformanceBytes(budgets.startupCssGzipBytes)} gzip)`,
` largest JS: ${metrics.largest.js.file}, ${formatControlUiPerformanceBytes(metrics.largest.js.gzipBytes)} gzip (limit: ${formatControlUiPerformanceBytes(budgets.largestJsGzipBytes)})`,
` largest CSS: ${metrics.largest.css.file}, ${formatControlUiPerformanceBytes(metrics.largest.css.gzipBytes)} gzip (limit: ${formatControlUiPerformanceBytes(budgets.largestCssGzipBytes)})`,
` all JS: ${formatAssetSummary(metrics.total.js)}`,
` all CSS: ${formatAssetSummary(metrics.total.css)}`,
];
if (violations.length > 0) {
lines.push(
" violations:",
...violations.map((violation) => ` - ${formatViolation(violation)}`),
);
}
return lines.join("\n");
}
export function runControlUiPerformanceCheck(distDir, budgets = CONTROL_UI_PERFORMANCE_BUDGETS) {
const metrics = collectControlUiPerformanceMetrics(distDir);
return {
metrics,
budgets,
violations: evaluateControlUiPerformanceBudgets(metrics, budgets),
report: formatControlUiPerformanceReport(metrics, budgets),
};
}
function main(argv = process.argv.slice(2)) {
const unknown = argv.filter((arg) => arg !== "--json");
if (unknown.length > 0) {
throw new Error(`Unknown option: ${unknown[0]}`);
}
const here = path.dirname(fileURLToPath(import.meta.url));
const result = runControlUiPerformanceCheck(path.resolve(here, "../dist/control-ui"));
if (argv.includes("--json")) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
} else {
process.stdout.write(`${result.report}\n`);
}
if (result.violations.length > 0) {
process.exitCode = 1;
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
main();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
+115
View File
@@ -0,0 +1,115 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
collectControlUiPerformanceMetrics,
evaluateControlUiPerformanceBudgets,
extractControlUiStartupAssetPaths,
formatControlUiPerformanceReport,
} from "../../scripts/check-control-ui-performance.mjs";
const tempDirs: string[] = [];
function createDistFixture() {
const distDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-control-ui-performance-"));
const assetsDir = path.join(distDir, "assets");
fs.mkdirSync(assetsDir);
tempDirs.push(distDir);
const writeAsset = (
file: string,
sizes: { rawBytes: number; gzipBytes: number; brotliBytes: number },
) => {
const assetPath = path.join(assetsDir, file);
fs.writeFileSync(assetPath, Buffer.alloc(sizes.rawBytes));
fs.writeFileSync(`${assetPath}.gz`, Buffer.alloc(sizes.gzipBytes));
fs.writeFileSync(`${assetPath}.br`, Buffer.alloc(sizes.brotliBytes));
};
return { distDir, writeAsset };
}
afterEach(() => {
for (const tempDir of tempDirs.splice(0)) {
fs.rmSync(tempDir, { force: true, recursive: true });
}
});
describe("Control UI performance budgets", () => {
it("extracts startup assets across relative and base-prefixed URLs", () => {
expect(
extractControlUiStartupAssetPaths(`
<script type="module" src="./assets/index-abc.js?build=1"></script>
<link rel="modulepreload" href="/control/assets/runtime-def.js">
<link rel="stylesheet" href="./assets/index-abc.css#theme">
<script data-src="./assets/deferred.js"></script>
<link rel="manifest" href="./manifest.webmanifest">
`),
).toEqual(["assets/index-abc.css", "assets/index-abc.js", "assets/runtime-def.js"]);
});
it("reports startup, total, and largest compressed assets", () => {
const { distDir, writeAsset } = createDistFixture();
fs.writeFileSync(
path.join(distDir, "index.html"),
'<script type="module" src="./assets/index-a.js"></script>\n' +
'<link rel="modulepreload" href="./assets/runtime-b.js">\n' +
'<link rel="stylesheet" href="./assets/index-c.css">\n',
);
writeAsset("index-a.js", { rawBytes: 100, gzipBytes: 40, brotliBytes: 30 });
writeAsset("runtime-b.js", { rawBytes: 80, gzipBytes: 25, brotliBytes: 20 });
writeAsset("lazy-d.js", { rawBytes: 200, gzipBytes: 70, brotliBytes: 55 });
writeAsset("index-c.css", { rawBytes: 50, gzipBytes: 15, brotliBytes: 12 });
const metrics = collectControlUiPerformanceMetrics(distDir);
expect(metrics.startup.js).toEqual({
requests: 2,
rawBytes: 180,
gzipBytes: 65,
brotliBytes: 50,
});
expect(metrics.startup.css.gzipBytes).toBe(15);
expect(metrics.total.js).toMatchObject({ requests: 3, rawBytes: 380, gzipBytes: 135 });
expect(metrics.largest.js.file).toBe("assets/lazy-d.js");
expect(metrics.largest.css.file).toBe("assets/index-c.css");
expect(formatControlUiPerformanceReport(metrics)).toContain("startup CSS: 1 request");
});
it("returns actionable violations and includes them in the report", () => {
const { distDir, writeAsset } = createDistFixture();
fs.writeFileSync(
path.join(distDir, "index.html"),
'<script type="module" src="./assets/index-a.js"></script>\n' +
'<link rel="stylesheet" href="./assets/index-c.css">\n',
);
writeAsset("index-a.js", { rawBytes: 100, gzipBytes: 40, brotliBytes: 30 });
writeAsset("index-c.css", { rawBytes: 50, gzipBytes: 15, brotliBytes: 12 });
const metrics = collectControlUiPerformanceMetrics(distDir);
const budgets = {
startupJsRequests: 0,
startupCssRequests: 1,
startupJsGzipBytes: 30,
startupCssGzipBytes: 20,
largestJsGzipBytes: 35,
largestCssGzipBytes: 20,
};
expect(
evaluateControlUiPerformanceBudgets(metrics, budgets).map((entry) => entry.metric),
).toEqual(["startup JS requests", "startup JS gzip", "largest JS gzip"]);
expect(formatControlUiPerformanceReport(metrics, budgets)).toContain(
"startup JS gzip: 40 B exceeds 30 B",
);
});
it("fails when a compressed sidecar is missing", () => {
const { distDir } = createDistFixture();
fs.writeFileSync(
path.join(distDir, "index.html"),
'<script type="module" src="./assets/index-a.js"></script>\n',
);
fs.writeFileSync(path.join(distDir, "assets/index-a.js"), "source");
expect(() => collectControlUiPerformanceMetrics(distDir)).toThrow("missing index-a.js.gz");
});
});