import { spawnSync } from "node:child_process";
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,
runControlUiPerformanceCheck,
} 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 };
}
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 });
}
});
describe("Control UI performance budgets", () => {
it("extracts startup assets across relative and base-prefixed URLs", () => {
expect(
extractControlUiStartupAssetPaths(`
`),
).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"),
'\n' +
'\n' +
'\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"),
'\n' +
'\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("includes exact bytes when rounded violation values collide", () => {
const metrics = {
schemaVersion: 1 as const,
startup: {
js: { requests: 1, rawBytes: 100, gzipBytes: 43_009, brotliBytes: 30 },
css: { requests: 1, rawBytes: 50, gzipBytes: 15, brotliBytes: 12 },
assets: [],
},
total: {
js: { requests: 1, rawBytes: 100, gzipBytes: 43_009, brotliBytes: 30 },
css: { requests: 1, rawBytes: 50, gzipBytes: 15, brotliBytes: 12 },
},
largest: {
js: {
file: "assets/index-a.js",
type: "js",
rawBytes: 100,
gzipBytes: 43_009,
brotliBytes: 30,
},
css: {
file: "assets/index-c.css",
type: "css",
rawBytes: 50,
gzipBytes: 15,
brotliBytes: 12,
},
},
} satisfies ReturnType;
const budgets = {
startupJsRequests: 1,
startupCssRequests: 1,
startupJsGzipBytes: 43_008,
startupCssGzipBytes: 20,
largestJsGzipBytes: 43_008,
largestCssGzipBytes: 20,
};
expect(formatControlUiPerformanceReport(metrics, budgets)).toContain(
"startup JS gzip: 42.0 KiB exceeds 42.0 KiB (43009 B vs 43008 B)",
);
});
it("allows startup JS growth within the ratchet tolerance", () => {
const violations = evaluateControlUiPerformanceBudgets(
createMetrics(11_024),
looseBudgets,
startupBaseline(10_000),
);
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);
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 scripts/check-control-ui-performance.mjs --update-baseline --startup-js-bytes 11025 --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 local or explicit CI metrics", () => {
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" });
fs.rmSync(distDir, { recursive: true });
const explicitBytesResult = spawnSync(
process.execPath,
[
fs.realpathSync(scriptPath),
"--update-baseline",
"--startup-js-bytes",
"321",
"--reason",
"CI measurement",
],
{ cwd: rootDir, encoding: "utf8" },
);
expect(explicitBytesResult.status, explicitBytesResult.stderr).toBe(0);
expect(
JSON.parse(
fs.readFileSync(path.join(configDir, "control-ui-startup-budget-baseline.json"), "utf8"),
),
).toMatchObject({ startupJsGzipBytes: 321, reason: "CI measurement" });
const beyondRatchetResult = spawnSync(
process.execPath,
[fs.realpathSync(scriptPath), "--update-baseline", "--startup-js-bytes", "4418"],
{ cwd: rootDir, encoding: "utf8" },
);
expect(beyondRatchetResult.status).toBe(1);
expect(beyondRatchetResult.stderr).toContain(
"4418 B differs from current baseline 321 B by 4097 B, exceeding the 4096 B ratchet",
);
expect(
JSON.parse(
fs.readFileSync(path.join(configDir, "control-ui-startup-budget-baseline.json"), "utf8"),
),
).toMatchObject({ startupJsGzipBytes: 321, reason: "CI measurement" });
});
it("fails when a compressed sidecar is missing", () => {
const { distDir } = createDistFixture();
fs.writeFileSync(
path.join(distDir, "index.html"),
'\n',
);
fs.writeFileSync(path.join(distDir, "assets/index-a.js"), "source");
expect(() => collectControlUiPerformanceMetrics(distDir)).toThrow("missing index-a.js.gz");
});
});