mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ci): make Control UI artifact validation consistent (#122086)
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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
@@ -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)(
|
||||
|
||||
Reference in New Issue
Block a user