fix(ci): make Control UI artifact validation consistent (#122086)

This commit is contained in:
Vincent Koc
2026-08-12 00:11:22 +08:00
committed by GitHub
parent 6e9e569243
commit 8d3f3377da
7 changed files with 160 additions and 68 deletions
+2
View File
@@ -21,6 +21,8 @@ const repositoryScriptEntries = [
"apps/android/scripts/build-release-artifacts.ts!",
"scripts/bundle-a2ui.mts!",
"scripts/build-discord-activity-sdk.mts!",
"scripts/check-control-ui-performance.mts!",
"scripts/check-control-ui-precompressed-assets.mts!",
"scripts/check-live-cache.ts!",
"scripts/check-package-dist-imports.mjs!",
"scripts/dev/ios-node-e2e.ts!",
+1 -1
View File
@@ -1995,7 +1995,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 --import tsx scripts/check-control-ui-precompressed-assets.mts && node --import tsx scripts/check-control-ui-performance.mts",
"ui:build": "node scripts/ui.js build",
"ui:dev": "node scripts/ui.js dev",
"ui:i18n:baseline": "node --import tsx scripts/control-ui-i18n-verify.ts baseline",
"ui:i18n:check": "node --import tsx scripts/control-ui-i18n.ts check",
+27 -31
View File
@@ -20,12 +20,8 @@ const DEFAULT_STARTUP_BUDGET_BASELINE_PATH = path.resolve(
// This absorbs measured local-to-Linux gzip variance, but landed changes can
// still consume the tolerance. Local zlib emits smaller streams than CI's Linux
// builder, so baseline updates must use CI bytes via --startup-js-bytes. The
// fixed JS ceiling bounds cumulative creep.
export const CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES = 1024;
// The startup bundle embeds commit SHA and timestamp identity. Those fixed-length,
// high-entropy values move Linux gzip output by tens of bytes between identical builds.
export const CONTROL_UI_STARTUP_JS_GZIP_IDENTITY_VARIANCE_BYTES = 64;
// fixed JS baseline ceiling bounds cumulative creep.
const CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES = 1024;
// Small, explicit headroom over the optimized baseline. Budget changes should
// accompany an intentional loading or chunking decision.
@@ -150,13 +146,15 @@ export function evaluateControlUiPerformanceBudgets(
startupBudgetBaseline: Readonly<ControlUiStartupBudgetBaseline> | null = null,
startupJsTolerance = CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES,
) {
const startupJsFixedLimit =
budgets.startupJsGzipBytes +
(startupBudgetBaseline ? CONTROL_UI_STARTUP_JS_GZIP_IDENTITY_VARIANCE_BYTES : 0);
const baselineBytes = startupBudgetBaseline?.startupJsGzipBytes;
const startupJsGzipLimit =
baselineBytes === undefined
? budgets.startupJsGzipBytes
: Math.min(baselineBytes, budgets.startupJsGzipBytes) + startupJsTolerance;
const checks: Array<[string, number, number, "count" | "bytes"]> = [
["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, startupJsFixedLimit, "bytes"],
["startup JS gzip", metrics.startup.js.gzipBytes, startupJsGzipLimit, "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"],
@@ -164,27 +162,22 @@ export function evaluateControlUiPerformanceBudgets(
const violations = checks.flatMap(([metric, actual, limit, unit]) =>
actual > limit ? [{ metric, actual, limit, unit }] : [],
);
return startupBudgetBaseline &&
metrics.startup.js.gzipBytes > startupBudgetBaseline.startupJsGzipBytes + startupJsTolerance
? [
...violations,
{
metric: "startup JS gzip vs baseline",
actual: metrics.startup.js.gzipBytes,
limit: startupBudgetBaseline.startupJsGzipBytes + startupJsTolerance,
unit: "bytes" as const,
baseline: startupBudgetBaseline.startupJsGzipBytes,
tolerance: startupJsTolerance,
},
]
: violations;
if (baselineBytes !== undefined && baselineBytes > budgets.startupJsGzipBytes) {
violations.unshift({
metric: "startup JS gzip baseline",
actual: baselineBytes,
limit: budgets.startupJsGzipBytes,
unit: "bytes",
});
}
return violations;
}
type ControlUiPerformanceBudgetViolation = ReturnType<
typeof evaluateControlUiPerformanceBudgets
>[number];
export function formatControlUiPerformanceBytes(bytes: number): string {
function formatControlUiPerformanceBytes(bytes: number): string {
return bytes < KIB ? `${bytes} B` : `${(bytes / KIB).toFixed(1)} KiB`;
}
@@ -197,9 +190,6 @@ function formatAssetSummary(summary: ReturnType<typeof summarizeAssets>): string
}
function formatViolation(violation: ControlUiPerformanceBudgetViolation): string {
if ("baseline" in violation && "tolerance" in violation) {
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 --import tsx scripts/check-control-ui-performance.mts --update-baseline --startup-js-bytes ${violation.actual} --reason "<reason>"`;
}
const actual =
violation.unit === "bytes"
? formatControlUiPerformanceBytes(violation.actual)
@@ -229,11 +219,11 @@ export function formatControlUiPerformanceReport(
);
const lines = [
"Control UI performance:",
` startup JS: ${formatAssetSummary(metrics.startup.js)} (limits: ${formatRequestCount(budgets.startupJsRequests)}, ${formatControlUiPerformanceBytes(budgets.startupJsGzipBytes)} gzip)`,
` startup JS: ${formatAssetSummary(metrics.startup.js)} (limits: ${formatRequestCount(budgets.startupJsRequests)}, ${formatControlUiPerformanceBytes(startupBudgetBaseline ? Math.min(startupBudgetBaseline.startupJsGzipBytes, budgets.startupJsGzipBytes) + startupJsTolerance : 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 + build-identity variance ${CONTROL_UI_STARTUP_JS_GZIP_IDENTITY_VARIANCE_BYTES} B)`,
` startup JS gzip vs baseline: ${metrics.startup.js.gzipBytes} B (baseline ${startupBudgetBaseline.startupJsGzipBytes} B + tolerance ${startupJsTolerance} B, max committed baseline ${budgets.startupJsGzipBytes} B)`,
);
}
lines.push(
@@ -282,12 +272,15 @@ function readControlUiStartupBudgetBaseline(baselinePath: string): ControlUiStar
typeof startupJsGzipBytes !== "number" ||
!Number.isSafeInteger(startupJsGzipBytes) ||
startupJsGzipBytes < 0 ||
startupJsGzipBytes > CONTROL_UI_PERFORMANCE_BUDGETS.startupJsGzipBytes ||
typeof reason !== "string" ||
reason.trim().length === 0 ||
typeof updatedAt !== "string" ||
!isIsoDate(updatedAt)
) {
throw new Error("expected startupJsGzipBytes, non-empty reason, and YYYY-MM-DD updatedAt");
throw new Error(
`expected startupJsGzipBytes at most ${CONTROL_UI_PERFORMANCE_BUDGETS.startupJsGzipBytes}, non-empty reason, and YYYY-MM-DD updatedAt`,
);
}
return { startupJsGzipBytes, reason, updatedAt };
} catch (error) {
@@ -304,6 +297,9 @@ function writeControlUiStartupBudgetBaseline(
startupJsGzipBytes: number,
reason: string,
) {
if (startupJsGzipBytes > CONTROL_UI_PERFORMANCE_BUDGETS.startupJsGzipBytes) {
throw new Error("startup JS gzip baseline exceeds the committed-baseline cap");
}
const baseline = {
startupJsGzipBytes,
reason,
+31 -10
View File
@@ -276,10 +276,6 @@ function signalProcessTree(child: ChildProcess, signal: NodeJS.Signals, pids: nu
}
}
function run(cmd: string, args: string[]): void {
runSpawnCall(resolveSpawnCall(cmd, args), cmd);
}
function runPnpm(args: string[], envOverride?: NodeJS.ProcessEnv): void {
runSpawnCall(resolvePnpmSpawnCall(args, envOverride), "pnpm");
}
@@ -355,11 +351,6 @@ function main(argv: string[] = process.argv.slice(2)): void {
assertRealOutputRoot(path.join(repoRoot, "dist"));
}
if (process.env.OPENCLAW_BUILD_ALL_NO_PNPM === "1" && action === "build") {
run(process.execPath, [path.join(repoRoot, "node_modules/vite/bin/vite.js"), "build", ...rest]);
return;
}
if (action === "install") {
runPnpm(["install", ...rest]);
return;
@@ -368,12 +359,42 @@ function main(argv: string[] = process.argv.slice(2)): void {
return;
}
if (!depsInstalled(action === "test" ? "test" : "build")) {
const noPnpmBuild = action === "build" && process.env.OPENCLAW_BUILD_ALL_NO_PNPM === "1";
if (!noPnpmBuild && !depsInstalled(action === "test" ? "test" : "build")) {
const installEnv = process.env;
const installArgs = ["install"];
runPnpmSync(installArgs, installEnv);
}
if (action === "build") {
const buildCall = noPnpmBuild
? resolveSpawnCall(process.execPath, [
path.join(repoRoot, "node_modules/vite/bin/vite.js"),
"build",
...rest,
])
: resolvePnpmSpawnCall(["run", "build", ...rest]);
runSpawnCallSync(buildCall, "Control UI build");
if (rest.some((arg) => arg === "--help" || arg === "-h")) {
return;
}
for (const validator of [
"check-control-ui-precompressed-assets.mts",
"check-control-ui-performance.mts",
]) {
runSpawnCallSync(
resolveSpawnCall(
process.execPath,
["--import", "tsx", path.join(repoRoot, "scripts", validator)],
process.env,
{ cwd: repoRoot },
),
validator,
);
}
return;
}
runPnpm(["run", script, ...rest]);
}
+16
View File
@@ -275,6 +275,22 @@ describe("resolveBuildAllStep", () => {
});
});
it("routes no-pnpm UI builds through the canonical validation wrapper", () => {
expect(
resolveBuildAllStep(getBuildAllStep("ui:build"), {
nodeExecPath: "/custom/node",
env: { OPENCLAW_BUILD_ALL_NO_PNPM: "1" },
}),
).toEqual({
command: "/custom/node",
args: ["scripts/ui.js", "build"],
options: {
stdio: "inherit",
env: { OPENCLAW_BUILD_ALL_NO_PNPM: "1" },
},
});
});
it("restores startup metadata as a validator seed and refreshes it after validation", () => {
const step = getBuildAllStep("write-cli-startup-metadata");
+65 -24
View File
@@ -4,7 +4,7 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
CONTROL_UI_STARTUP_JS_GZIP_IDENTITY_VARIANCE_BYTES,
CONTROL_UI_PERFORMANCE_BUDGETS,
collectControlUiPerformanceMetrics,
evaluateControlUiPerformanceBudgets,
extractControlUiStartupAssetPaths,
@@ -205,52 +205,66 @@ describe("Control UI performance budgets", () => {
);
});
it("allows startup JS growth within the ratchet tolerance", () => {
it("allows CI-measured startup JS growth within the ratchet tolerance", () => {
const violations = evaluateControlUiPerformanceBudgets(
createMetrics(11_024),
looseBudgets,
startupBaseline(10_000),
createMetrics(326_672),
{ ...looseBudgets, startupJsGzipBytes: 319 * 1024, largestJsGzipBytes: 400_000 },
startupBaseline(325_675),
);
expect(violations).toEqual([]);
});
it("fails startup JS growth over the ratchet tolerance with update guidance", () => {
const metrics = createMetrics(11_025);
const baseline = startupBaseline(10_000);
const metrics = createMetrics(326_700);
const baseline = startupBaseline(325_675);
const budgets = {
...looseBudgets,
startupJsGzipBytes: 319 * 1024,
largestJsGzipBytes: 400_000,
};
expect(
evaluateControlUiPerformanceBudgets(metrics, looseBudgets, baseline).map(
(entry) => entry.metric,
),
).toContain("startup JS gzip vs baseline");
expect(formatControlUiPerformanceReport(metrics, looseBudgets, baseline)).toContain(
'11025 B exceeds baseline 10000 B + tolerance 1024 B (limit 11024 B); intentionally raise the baseline with node --import tsx scripts/check-control-ui-performance.mts --update-baseline --startup-js-bytes 11025 --reason "<reason>"',
evaluateControlUiPerformanceBudgets(metrics, budgets, baseline).map((entry) => entry.metric),
).toContain("startup JS gzip");
expect(formatControlUiPerformanceReport(metrics, budgets, baseline)).toContain(
"startup JS gzip: 319.0 KiB exceeds 319.0 KiB (326700 B vs 326699 B)",
);
expect(formatControlUiPerformanceReport(metrics, budgets, baseline)).toContain(
"limits: 10 requests, 319.0 KiB gzip",
);
});
it("enforces the fixed startup JS ceiling even when the baseline is higher", () => {
const budgets = { ...looseBudgets, startupJsGzipBytes: 10_000 };
it("rejects committed startup JS baselines above the fixed cap", () => {
const budgets = {
...looseBudgets,
startupJsGzipBytes: 319 * 1024,
largestJsGzipBytes: 400_000,
};
expect(
evaluateControlUiPerformanceBudgets(
createMetrics(10_000 + CONTROL_UI_STARTUP_JS_GZIP_IDENTITY_VARIANCE_BYTES + 1),
createMetrics(319 * 1024),
budgets,
startupBaseline(1_000_000),
startupBaseline(319 * 1024 + 1),
).map((entry) => entry.metric),
).toEqual(["startup JS gzip"]);
).toEqual(["startup JS gzip baseline"]);
});
it("absorbs bounded build-identity gzip variance at the fixed ceiling", () => {
const budgets = { ...looseBudgets, startupJsGzipBytes: 10_000 };
it("rejects startup JS measurements above the cap plus tolerance", () => {
const budgets = {
...looseBudgets,
startupJsGzipBytes: 319 * 1024,
largestJsGzipBytes: 400_000,
};
expect(
evaluateControlUiPerformanceBudgets(
createMetrics(10_000 + CONTROL_UI_STARTUP_JS_GZIP_IDENTITY_VARIANCE_BYTES),
createMetrics(320 * 1024 + 1),
budgets,
startupBaseline(10_000),
),
).toEqual([]);
startupBaseline(319 * 1024),
).map((entry) => entry.metric),
).toEqual(["startup JS gzip"]);
});
it("suggests lowering a baseline after a meaningful size reduction", () => {
@@ -280,6 +294,33 @@ describe("Control UI performance budgets", () => {
);
});
it("fails closed when the startup baseline exceeds the configured cap", () => {
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,
JSON.stringify({
startupJsGzipBytes: CONTROL_UI_PERFORMANCE_BUDGETS.startupJsGzipBytes + 1,
reason: "invalid test baseline",
updatedAt: "2026-08-11",
}),
);
expect(() => runControlUiPerformanceCheck(distDir, undefined, baselinePath)).toThrow(
new RegExp(
`startupJsGzipBytes at most ${CONTROL_UI_PERFORMANCE_BUDGETS.startupJsGzipBytes}`,
"u",
),
);
});
it("updates the baseline from local or explicit CI metrics", () => {
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-control-ui-budget-cli-"));
tempDirs.push(rootDir);
+18 -2
View File
@@ -201,8 +201,8 @@ describe("scripts/ui windows spawn behavior", () => {
expect(isDirectScriptExecution(junctionScriptPath, realScriptPath, realpath)).toBe(true);
});
it("honors build-all no-pnpm mode before requiring a pnpm runner", () => {
const result = spawnSync(process.execPath, ["scripts/ui.js", "build", "--help"], {
it.each(["--help", "-h"])("keeps no-pnpm build %s informational", (helpFlag) => {
const result = spawnSync(process.execPath, ["scripts/ui.js", "build", helpFlag], {
cwd: path.resolve("."),
encoding: "utf8",
env: {
@@ -216,6 +216,22 @@ describe("scripts/ui windows spawn behavior", () => {
expect(result.status).toBe(0);
expect(output).not.toContain("Missing UI runner");
expect(output).toContain("vite");
expect(output).not.toContain("Control UI performance");
});
it.each(["check-control-ui-precompressed-assets.mts", "check-control-ui-performance.mts"])(
"keeps %s in the canonical build wrapper",
(validator) => {
expect(fs.readFileSync("scripts/ui.mts", "utf8")).toContain(validator);
},
);
it("keeps the package script on the canonical UI build wrapper", () => {
const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")) as {
scripts: Record<string, string>;
};
expect(packageJson.scripts["ui:build"]).toBe("node scripts/ui.js build");
});
it.runIf(process.platform !== "win32").each(["SIGTERM", "SIGHUP"] as const)(