feat(ci): ratchet the Control UI startup JS budget against a committed baseline (#112880)

The startup JS gzip gate was a fixed ceiling that main drifted to within
1-3 KiB of, forcing repeated hand-bumps (315->317 KiB) and failing
feature PRs on drift they did not cause (#112649 burned two CI rounds).

check-control-ui-performance.mjs now also enforces a committed baseline
(config/control-ui-startup-budget-baseline.json) with a 512 B tolerance:
regressions beyond baseline+tolerance fail with an actionable message,
intentional increases become a reviewed one-line diff via
--update-baseline --reason, meaningfully lighter builds print a
lower-the-baseline hint, and a malformed/missing baseline fails closed.
The fixed ceiling stays as the cumulative-creep backstop.

Closes #112743.
This commit is contained in:
Peter Steinberger
2026-07-23 00:19:36 -04:00
committed by GitHub
parent a195d6fcee
commit c11f175282
4 changed files with 347 additions and 12 deletions
@@ -0,0 +1,5 @@
{
"startupJsGzipBytes": 322526,
"reason": "initial ratchet baseline",
"updatedAt": "2026-07-23"
}
@@ -33,31 +33,47 @@ export type ControlUiPerformanceBudgets = {
largestCssGzipBytes: number;
};
export type ControlUiStartupBudgetBaseline = {
startupJsGzipBytes: number;
reason: string;
updatedAt: string;
};
export type ControlUiPerformanceBudgetViolation = {
metric: string;
actual: number;
limit: number;
unit: "count" | "bytes";
baseline?: number;
tolerance?: number;
};
export const CONTROL_UI_PERFORMANCE_BUDGETS: Readonly<ControlUiPerformanceBudgets>;
export const CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES: 512;
export function extractControlUiStartupAssetPaths(html: string): string[];
export function collectControlUiPerformanceMetrics(distDir: string): ControlUiPerformanceMetrics;
export function evaluateControlUiPerformanceBudgets(
metrics: ControlUiPerformanceMetrics,
budgets?: Readonly<ControlUiPerformanceBudgets>,
startupBudgetBaseline?: Readonly<ControlUiStartupBudgetBaseline>,
startupJsTolerance?: number,
): ControlUiPerformanceBudgetViolation[];
export function formatControlUiPerformanceBytes(bytes: number): string;
export function formatControlUiPerformanceReport(
metrics: ControlUiPerformanceMetrics,
budgets?: Readonly<ControlUiPerformanceBudgets>,
startupBudgetBaseline?: Readonly<ControlUiStartupBudgetBaseline>,
startupJsTolerance?: number,
): string;
export function runControlUiPerformanceCheck(
distDir: string,
budgets?: Readonly<ControlUiPerformanceBudgets>,
baselinePath?: string,
): {
metrics: ControlUiPerformanceMetrics;
budgets: Readonly<ControlUiPerformanceBudgets>;
startupBudgetBaseline: ControlUiStartupBudgetBaseline;
startupJsTolerance: number;
violations: ControlUiPerformanceBudgetViolation[];
report: string;
};
+157 -12
View File
@@ -6,6 +6,16 @@ import process from "node:process";
import { fileURLToPath } from "node:url";
const KIB = 1024;
const STARTUP_JS_BASELINE_RATCHET_BYTES = 4096;
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_STARTUP_BUDGET_BASELINE_PATH = path.resolve(
SCRIPT_DIR,
"../config/control-ui-startup-budget-baseline.json",
);
// Each landed change can consume this much ratchet tolerance, so small increases
// may accumulate. The fixed startup JS ceiling bounds that cumulative creep.
export const CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES = 512;
// Small, explicit headroom over the optimized baseline. Budget changes should
// accompany an intentional loading or chunking decision.
@@ -125,6 +135,8 @@ export function collectControlUiPerformanceMetrics(distDir) {
export function evaluateControlUiPerformanceBudgets(
metrics,
budgets = CONTROL_UI_PERFORMANCE_BUDGETS,
startupBudgetBaseline = null,
startupJsTolerance = CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES,
) {
const checks = [
["startup JS requests", metrics.startup.js.requests, budgets.startupJsRequests, "count"],
@@ -134,9 +146,23 @@ export function evaluateControlUiPerformanceBudgets(
["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]) =>
const violations = checks.flatMap(([metric, actual, limit, unit]) =>
actual > limit ? [{ metric, actual, limit, unit }] : [],
);
if (
startupBudgetBaseline &&
metrics.startup.js.gzipBytes > startupBudgetBaseline.startupJsGzipBytes + startupJsTolerance
) {
violations.push({
metric: "startup JS gzip vs baseline",
actual: metrics.startup.js.gzipBytes,
limit: startupBudgetBaseline.startupJsGzipBytes + startupJsTolerance,
unit: "bytes",
baseline: startupBudgetBaseline.startupJsGzipBytes,
tolerance: startupJsTolerance,
});
}
return violations;
}
export function formatControlUiPerformanceBytes(bytes) {
@@ -152,6 +178,9 @@ function formatAssetSummary(summary) {
}
function formatViolation(violation) {
if (violation.baseline !== undefined && violation.tolerance !== undefined) {
return `${violation.metric}: ${violation.actual} B exceeds baseline ${violation.baseline} B + tolerance ${violation.tolerance} B (limit ${violation.limit} B); intentionally raise the baseline with node scripts/check-control-ui-performance.mjs --update-baseline --reason "<reason>"`;
}
const actual =
violation.unit === "bytes"
? formatControlUiPerformanceBytes(violation.actual)
@@ -170,17 +199,40 @@ function formatViolation(violation) {
export function formatControlUiPerformanceReport(
metrics,
budgets = CONTROL_UI_PERFORMANCE_BUDGETS,
startupBudgetBaseline = null,
startupJsTolerance = CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES,
) {
const violations = evaluateControlUiPerformanceBudgets(metrics, budgets);
const violations = evaluateControlUiPerformanceBudgets(
metrics,
budgets,
startupBudgetBaseline,
startupJsTolerance,
);
const lines = [
"Control UI performance:",
` startup JS: ${formatAssetSummary(metrics.startup.js)} (limits: ${formatRequestCount(budgets.startupJsRequests)}, ${formatControlUiPerformanceBytes(budgets.startupJsGzipBytes)} gzip)`,
];
if (startupBudgetBaseline) {
lines.push(
` startup JS gzip vs baseline: ${metrics.startup.js.gzipBytes} B (baseline ${startupBudgetBaseline.startupJsGzipBytes} B + tolerance ${startupJsTolerance} B, ceiling ${budgets.startupJsGzipBytes} B)`,
);
}
lines.push(
` 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 (
startupBudgetBaseline &&
metrics.startup.js.gzipBytes + STARTUP_JS_BASELINE_RATCHET_BYTES <
startupBudgetBaseline.startupJsGzipBytes
) {
lines.push(
` hint: startup JS gzip is more than ${STARTUP_JS_BASELINE_RATCHET_BYTES} B below the ${startupBudgetBaseline.startupJsGzipBytes} B baseline; lower it with node scripts/check-control-ui-performance.mjs --update-baseline --reason "<reason>"`,
);
}
if (violations.length > 0) {
lines.push(
" violations:",
@@ -190,24 +242,117 @@ export function formatControlUiPerformanceReport(
return lines.join("\n");
}
export function runControlUiPerformanceCheck(distDir, budgets = CONTROL_UI_PERFORMANCE_BUDGETS) {
function baselineUpdateCommand() {
return 'node scripts/check-control-ui-performance.mjs --update-baseline --reason "<reason>"';
}
function isIsoDate(value) {
if (!/^\d{4}-\d{2}-\d{2}$/u.test(value)) {
return false;
}
const date = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(date.valueOf()) && date.toISOString().slice(0, 10) === value;
}
function readControlUiStartupBudgetBaseline(baselinePath) {
try {
const parsed = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
if (
!parsed ||
typeof parsed !== "object" ||
!Number.isSafeInteger(parsed.startupJsGzipBytes) ||
parsed.startupJsGzipBytes < 0 ||
typeof parsed.reason !== "string" ||
parsed.reason.trim().length === 0 ||
typeof parsed.updatedAt !== "string" ||
!isIsoDate(parsed.updatedAt)
) {
throw new Error("expected startupJsGzipBytes, non-empty reason, and YYYY-MM-DD updatedAt");
}
return {
startupJsGzipBytes: parsed.startupJsGzipBytes,
reason: parsed.reason,
updatedAt: parsed.updatedAt,
};
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(
`Cannot read Control UI startup budget baseline ${baselinePath}: ${detail}. Regenerate it with ${baselineUpdateCommand()}.`,
{ cause: error },
);
}
}
function writeControlUiStartupBudgetBaseline(baselinePath, startupJsGzipBytes, reason) {
const baseline = {
startupJsGzipBytes,
reason,
updatedAt: new Date().toISOString().slice(0, 10),
};
fs.writeFileSync(baselinePath, `${JSON.stringify(baseline, null, 2)}\n`);
return baseline;
}
export function runControlUiPerformanceCheck(
distDir,
budgets = CONTROL_UI_PERFORMANCE_BUDGETS,
baselinePath = DEFAULT_STARTUP_BUDGET_BASELINE_PATH,
) {
const startupBudgetBaseline = readControlUiStartupBudgetBaseline(baselinePath);
const metrics = collectControlUiPerformanceMetrics(distDir);
const violations = evaluateControlUiPerformanceBudgets(metrics, budgets, startupBudgetBaseline);
const report = formatControlUiPerformanceReport(metrics, budgets, startupBudgetBaseline);
return {
metrics,
budgets,
violations: evaluateControlUiPerformanceBudgets(metrics, budgets),
report: formatControlUiPerformanceReport(metrics, budgets),
startupBudgetBaseline,
startupJsTolerance: CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES,
violations,
report,
};
}
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]}`);
let json = false;
let updateBaseline = false;
let reason;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--json") {
json = true;
} else if (arg === "--update-baseline") {
updateBaseline = true;
} else if (arg === "--reason") {
reason = argv[index + 1];
if (!reason || reason.trim().length === 0 || reason.startsWith("--")) {
throw new Error("--reason requires a non-empty value");
}
index += 1;
} else {
throw new Error(`Unknown option: ${arg}`);
}
}
const here = path.dirname(fileURLToPath(import.meta.url));
const result = runControlUiPerformanceCheck(path.resolve(here, "../dist/control-ui"));
if (argv.includes("--json")) {
if (reason !== undefined && !updateBaseline) {
throw new Error("--reason requires --update-baseline");
}
if (json && updateBaseline) {
throw new Error("--json cannot be combined with --update-baseline");
}
const distDir = path.resolve(SCRIPT_DIR, "../dist/control-ui");
if (updateBaseline) {
const metrics = collectControlUiPerformanceMetrics(distDir);
const baseline = writeControlUiStartupBudgetBaseline(
DEFAULT_STARTUP_BUDGET_BASELINE_PATH,
metrics.startup.js.gzipBytes,
reason ?? "manual baseline update",
);
process.stdout.write(
`Updated config/control-ui-startup-budget-baseline.json to ${baseline.startupJsGzipBytes} B (${baseline.reason}).\n`,
);
return;
}
const result = runControlUiPerformanceCheck(distDir);
if (json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
} else {
process.stdout.write(`${result.report}\n`);
+169
View File
@@ -1,3 +1,4 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -7,6 +8,7 @@ import {
evaluateControlUiPerformanceBudgets,
extractControlUiStartupAssetPaths,
formatControlUiPerformanceReport,
runControlUiPerformanceCheck,
} from "../../scripts/check-control-ui-performance.mjs";
const tempDirs: string[] = [];
@@ -28,6 +30,54 @@ function createDistFixture() {
return { distDir, writeAsset };
}
function createMetrics(startupJsGzipBytes: number) {
return {
schemaVersion: 1 as const,
startup: {
js: { requests: 1, rawBytes: 2_000, gzipBytes: startupJsGzipBytes, brotliBytes: 900 },
css: { requests: 1, rawBytes: 50, gzipBytes: 15, brotliBytes: 12 },
assets: [],
},
total: {
js: { requests: 1, rawBytes: 2_000, gzipBytes: startupJsGzipBytes, brotliBytes: 900 },
css: { requests: 1, rawBytes: 50, gzipBytes: 15, brotliBytes: 12 },
},
largest: {
js: {
file: "assets/index-a.js",
type: "js" as const,
rawBytes: 2_000,
gzipBytes: startupJsGzipBytes,
brotliBytes: 900,
},
css: {
file: "assets/index-c.css",
type: "css" as const,
rawBytes: 50,
gzipBytes: 15,
brotliBytes: 12,
},
},
};
}
const looseBudgets = {
startupJsRequests: 10,
startupCssRequests: 10,
startupJsGzipBytes: 100_000,
startupCssGzipBytes: 100_000,
largestJsGzipBytes: 100_000,
largestCssGzipBytes: 100_000,
};
function startupBaseline(startupJsGzipBytes: number) {
return {
startupJsGzipBytes,
reason: "test baseline",
updatedAt: "2026-07-22",
};
}
afterEach(() => {
for (const tempDir of tempDirs.splice(0)) {
fs.rmSync(tempDir, { force: true, recursive: true });
@@ -145,6 +195,125 @@ describe("Control UI performance budgets", () => {
);
});
it("allows startup JS growth within the ratchet tolerance", () => {
const violations = evaluateControlUiPerformanceBudgets(
createMetrics(10_512),
looseBudgets,
startupBaseline(10_000),
);
expect(violations).toEqual([]);
});
it("fails startup JS growth over the ratchet tolerance with update guidance", () => {
const metrics = createMetrics(10_513);
const baseline = startupBaseline(10_000);
expect(
evaluateControlUiPerformanceBudgets(metrics, looseBudgets, baseline).map(
(entry) => entry.metric,
),
).toContain("startup JS gzip vs baseline");
expect(formatControlUiPerformanceReport(metrics, looseBudgets, baseline)).toContain(
'10513 B exceeds baseline 10000 B + tolerance 512 B (limit 10512 B); intentionally raise the baseline with node scripts/check-control-ui-performance.mjs --update-baseline --reason "<reason>"',
);
});
it("enforces the fixed startup JS ceiling even when the baseline is higher", () => {
const budgets = { ...looseBudgets, startupJsGzipBytes: 10_000 };
expect(
evaluateControlUiPerformanceBudgets(
createMetrics(10_001),
budgets,
startupBaseline(1_000_000),
).map((entry) => entry.metric),
).toEqual(["startup JS gzip"]);
});
it("suggests lowering a baseline after a meaningful size reduction", () => {
expect(
formatControlUiPerformanceReport(
createMetrics(10_000),
looseBudgets,
startupBaseline(14_097),
),
).toContain("hint: startup JS gzip is more than 4096 B below the 14097 B baseline");
});
it("fails closed when the startup baseline is malformed", () => {
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 baselinePath = path.join(distDir, "baseline.json");
fs.writeFileSync(baselinePath, '{"startupJsGzipBytes":"not-a-number"}\n');
expect(() => runControlUiPerformanceCheck(distDir, looseBudgets, baselinePath)).toThrow(
/Cannot read Control UI startup budget baseline .*--update-baseline/u,
);
});
it("updates the baseline from exact current dist metrics without rebuilding", () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-control-ui-budget-cli-"));
tempDirs.push(rootDir);
const scriptsDir = path.join(rootDir, "scripts");
const configDir = path.join(rootDir, "config");
const distDir = path.join(rootDir, "dist/control-ui");
const assetsDir = path.join(distDir, "assets");
fs.mkdirSync(scriptsDir, { recursive: true });
fs.mkdirSync(configDir, { recursive: true });
fs.mkdirSync(assetsDir, { recursive: true });
const scriptPath = path.join(scriptsDir, "check-control-ui-performance.mjs");
fs.copyFileSync(path.resolve("scripts/check-control-ui-performance.mjs"), scriptPath);
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',
);
for (const [file, sizes] of [
["index-a.js", { rawBytes: 100, gzipBytes: 65, brotliBytes: 50 }],
["index-c.css", { rawBytes: 50, gzipBytes: 15, brotliBytes: 12 }],
] as const) {
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));
}
const result = spawnSync(process.execPath, [fs.realpathSync(scriptPath), "--update-baseline"], {
cwd: rootDir,
encoding: "utf8",
});
expect(result.status, result.stderr).toBe(0);
expect(
JSON.parse(
fs.readFileSync(path.join(configDir, "control-ui-startup-budget-baseline.json"), "utf8"),
),
).toEqual({
startupJsGzipBytes: 65,
reason: "manual baseline update",
updatedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/u),
});
const customReasonResult = spawnSync(
process.execPath,
[fs.realpathSync(scriptPath), "--update-baseline", "--reason", "fixture update"],
{ cwd: rootDir, encoding: "utf8" },
);
expect(customReasonResult.status, customReasonResult.stderr).toBe(0);
expect(
JSON.parse(
fs.readFileSync(path.join(configDir, "control-ui-startup-budget-baseline.json"), "utf8"),
),
).toMatchObject({ startupJsGzipBytes: 65, reason: "fixture update" });
});
it("fails when a compressed sidecar is missing", () => {
const { distDir } = createDistFixture();
fs.writeFileSync(