diff --git a/config/control-ui-startup-budget-baseline.json b/config/control-ui-startup-budget-baseline.json new file mode 100644 index 000000000000..e73bd2b6ada6 --- /dev/null +++ b/config/control-ui-startup-budget-baseline.json @@ -0,0 +1,5 @@ +{ + "startupJsGzipBytes": 322526, + "reason": "initial ratchet baseline", + "updatedAt": "2026-07-23" +} diff --git a/scripts/check-control-ui-performance.d.mts b/scripts/check-control-ui-performance.d.mts index 724d52bb3b6a..b173d110cacb 100644 --- a/scripts/check-control-ui-performance.d.mts +++ b/scripts/check-control-ui-performance.d.mts @@ -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; +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, + startupBudgetBaseline?: Readonly, + startupJsTolerance?: number, ): ControlUiPerformanceBudgetViolation[]; export function formatControlUiPerformanceBytes(bytes: number): string; export function formatControlUiPerformanceReport( metrics: ControlUiPerformanceMetrics, budgets?: Readonly, + startupBudgetBaseline?: Readonly, + startupJsTolerance?: number, ): string; export function runControlUiPerformanceCheck( distDir: string, budgets?: Readonly, + baselinePath?: string, ): { metrics: ControlUiPerformanceMetrics; budgets: Readonly; + startupBudgetBaseline: ControlUiStartupBudgetBaseline; + startupJsTolerance: number; violations: ControlUiPerformanceBudgetViolation[]; report: string; }; diff --git a/scripts/check-control-ui-performance.mjs b/scripts/check-control-ui-performance.mjs index 83dbae62478f..06051162bec9 100644 --- a/scripts/check-control-ui-performance.mjs +++ b/scripts/check-control-ui-performance.mjs @@ -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 ""`; + } 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 ""`, + ); + } 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 ""'; +} + +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`); diff --git a/test/scripts/control-ui-performance.test.ts b/test/scripts/control-ui-performance.test.ts index cc52fdcc8c64..5f3e058d32e8 100644 --- a/test/scripts/control-ui-performance.test.ts +++ b/test/scripts/control-ui-performance.test.ts @@ -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 ""', + ); + }); + + 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"), + '\n' + + '\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"), + '\n' + + '\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(