mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
perf(ui): enforce build performance budgets (#106367)
This commit is contained in:
+1
-1
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user