diff --git a/package.json b/package.json index fb676743c482..6f8d55eb1f3c 100644 --- a/package.json +++ b/package.json @@ -1990,7 +1990,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 scripts/check-control-ui-precompressed-assets.mjs", + "ui:build": "node scripts/ui.js build && node scripts/check-control-ui-precompressed-assets.mjs && node scripts/check-control-ui-performance.mjs", "ui:dev": "node scripts/ui.js dev", "ui:i18n:check": "node --import tsx scripts/control-ui-i18n.ts check", "ui:i18n:report": "node --import tsx scripts/control-ui-i18n-report.ts", diff --git a/scripts/check-control-ui-performance.d.mts b/scripts/check-control-ui-performance.d.mts new file mode 100644 index 000000000000..724d52bb3b6a --- /dev/null +++ b/scripts/check-control-ui-performance.d.mts @@ -0,0 +1,63 @@ +export type ControlUiAssetMetrics = { + file: string; + type: "js" | "css"; + rawBytes: number; + gzipBytes: number; + brotliBytes: number; +}; + +export type ControlUiAssetSummary = { + requests: number; + rawBytes: number; + gzipBytes: number; + brotliBytes: number; +}; + +export type ControlUiPerformanceMetrics = { + schemaVersion: 1; + startup: { + js: ControlUiAssetSummary; + css: ControlUiAssetSummary; + assets: ControlUiAssetMetrics[]; + }; + total: { js: ControlUiAssetSummary; css: ControlUiAssetSummary }; + largest: { js: ControlUiAssetMetrics; css: ControlUiAssetMetrics }; +}; + +export type ControlUiPerformanceBudgets = { + startupJsRequests: number; + startupCssRequests: number; + startupJsGzipBytes: number; + startupCssGzipBytes: number; + largestJsGzipBytes: number; + largestCssGzipBytes: number; +}; + +export type ControlUiPerformanceBudgetViolation = { + metric: string; + actual: number; + limit: number; + unit: "count" | "bytes"; +}; + +export const CONTROL_UI_PERFORMANCE_BUDGETS: Readonly; +export function extractControlUiStartupAssetPaths(html: string): string[]; +export function collectControlUiPerformanceMetrics(distDir: string): ControlUiPerformanceMetrics; +export function evaluateControlUiPerformanceBudgets( + metrics: ControlUiPerformanceMetrics, + budgets?: Readonly, +): ControlUiPerformanceBudgetViolation[]; +export function formatControlUiPerformanceBytes(bytes: number): string; +export function formatControlUiPerformanceReport( + metrics: ControlUiPerformanceMetrics, + budgets?: Readonly, +): string; +export function runControlUiPerformanceCheck( + distDir: string, + budgets?: Readonly, +): { + metrics: ControlUiPerformanceMetrics; + budgets: Readonly; + violations: ControlUiPerformanceBudgetViolation[]; + report: string; +}; diff --git a/scripts/check-control-ui-performance.mjs b/scripts/check-control-ui-performance.mjs new file mode 100644 index 000000000000..e4f9aa306f62 --- /dev/null +++ b/scripts/check-control-ui-performance.mjs @@ -0,0 +1,219 @@ +#!/usr/bin/env node +// Reports and enforces compressed Control UI asset budgets after a production build. +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const KIB = 1024; + +// Small, explicit headroom over the optimized baseline. Budget changes should +// accompany an intentional loading or chunking decision. +export const CONTROL_UI_PERFORMANCE_BUDGETS = Object.freeze({ + startupJsRequests: 24, + startupCssRequests: 1, + startupJsGzipBytes: 320 * KIB, + startupCssGzipBytes: 42 * KIB, + largestJsGzipBytes: 205 * KIB, + largestCssGzipBytes: 42 * KIB, +}); + +function controlUiAssetPathFromUrl(value) { + const normalized = value.split(/[?#]/u, 1)[0]?.replace(/\\/gu, "/") ?? ""; + const markerIndex = normalized.lastIndexOf("assets/"); + if (markerIndex === -1) { + return null; + } + const assetPath = normalized.slice(markerIndex); + if (assetPath.includes("../") || !/\.(?:css|js)$/u.test(assetPath)) { + return null; + } + return assetPath; +} + +export function extractControlUiStartupAssetPaths(html) { + const assets = new Set(); + for (const tag of html.matchAll(/<(?:link|script)\b[^>]*>/giu)) { + const attribute = tag[0].match(/\s(?:href|src)\s*=\s*["']([^"']+)["']/iu); + const assetPath = attribute ? controlUiAssetPathFromUrl(attribute[1]) : null; + if (assetPath) { + assets.add(assetPath); + } + } + return [...assets].toSorted(); +} + +function readAssetMetrics(assetsDir, entry) { + const file = `assets/${entry.name}`; + const sourcePath = path.join(assetsDir, entry.name); + const gzipPath = `${sourcePath}.gz`; + const brotliPath = `${sourcePath}.br`; + for (const sidecarPath of [gzipPath, brotliPath]) { + if (!fs.existsSync(sidecarPath)) { + throw new Error(`Control UI performance check missing ${path.basename(sidecarPath)}`); + } + } + return { + file, + type: entry.name.endsWith(".js") ? "js" : "css", + rawBytes: fs.statSync(sourcePath).size, + gzipBytes: fs.statSync(gzipPath).size, + brotliBytes: fs.statSync(brotliPath).size, + }; +} + +function summarizeAssets(assets) { + return assets.reduce( + (summary, asset) => ({ + requests: summary.requests + 1, + rawBytes: summary.rawBytes + asset.rawBytes, + gzipBytes: summary.gzipBytes + asset.gzipBytes, + brotliBytes: summary.brotliBytes + asset.brotliBytes, + }), + { requests: 0, rawBytes: 0, gzipBytes: 0, brotliBytes: 0 }, + ); +} + +function largestAsset(assets) { + return assets.toSorted( + (left, right) => right.gzipBytes - left.gzipBytes || left.file.localeCompare(right.file), + )[0]; +} + +export function collectControlUiPerformanceMetrics(distDir) { + const assetsDir = path.join(distDir, "assets"); + const html = fs.readFileSync(path.join(distDir, "index.html"), "utf8"); + const assets = fs + .readdirSync(assetsDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && /\.(?:css|js)$/u.test(entry.name)) + .map((entry) => readAssetMetrics(assetsDir, entry)); + const assetsByFile = new Map(assets.map((asset) => [asset.file, asset])); + const startup = extractControlUiStartupAssetPaths(html).map((file) => { + const asset = assetsByFile.get(file); + if (!asset) { + throw new Error(`Control UI performance check cannot find startup asset ${file}`); + } + return asset; + }); + const jsAssets = assets.filter((asset) => asset.type === "js"); + const cssAssets = assets.filter((asset) => asset.type === "css"); + if (jsAssets.length === 0 || cssAssets.length === 0 || startup.length === 0) { + throw new Error("Control UI performance check found an incomplete production bundle"); + } + return { + schemaVersion: 1, + startup: { + js: summarizeAssets(startup.filter((asset) => asset.type === "js")), + css: summarizeAssets(startup.filter((asset) => asset.type === "css")), + assets: startup, + }, + total: { + js: summarizeAssets(jsAssets), + css: summarizeAssets(cssAssets), + }, + largest: { + js: largestAsset(jsAssets), + css: largestAsset(cssAssets), + }, + }; +} + +export function evaluateControlUiPerformanceBudgets( + metrics, + budgets = CONTROL_UI_PERFORMANCE_BUDGETS, +) { + const checks = [ + ["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, budgets.startupJsGzipBytes, "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"], + ]; + return checks.flatMap(([metric, actual, limit, unit]) => + actual > limit ? [{ metric, actual, limit, unit }] : [], + ); +} + +export function formatControlUiPerformanceBytes(bytes) { + return bytes < KIB ? `${bytes} B` : `${(bytes / KIB).toFixed(1)} KiB`; +} + +function formatRequestCount(count) { + return `${count} ${count === 1 ? "request" : "requests"}`; +} + +function formatAssetSummary(summary) { + return `${formatRequestCount(summary.requests)}, ${formatControlUiPerformanceBytes(summary.gzipBytes)} gzip, ${formatControlUiPerformanceBytes(summary.brotliBytes)} br`; +} + +function formatViolation(violation) { + const actual = + violation.unit === "bytes" + ? formatControlUiPerformanceBytes(violation.actual) + : String(violation.actual); + const limit = + violation.unit === "bytes" + ? formatControlUiPerformanceBytes(violation.limit) + : String(violation.limit); + return `${violation.metric}: ${actual} exceeds ${limit}`; +} + +export function formatControlUiPerformanceReport( + metrics, + budgets = CONTROL_UI_PERFORMANCE_BUDGETS, +) { + const violations = evaluateControlUiPerformanceBudgets(metrics, budgets); + const lines = [ + "Control UI performance:", + ` startup JS: ${formatAssetSummary(metrics.startup.js)} (limits: ${formatRequestCount(budgets.startupJsRequests)}, ${formatControlUiPerformanceBytes(budgets.startupJsGzipBytes)} gzip)`, + ` 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 (violations.length > 0) { + lines.push( + " violations:", + ...violations.map((violation) => ` - ${formatViolation(violation)}`), + ); + } + return lines.join("\n"); +} + +export function runControlUiPerformanceCheck(distDir, budgets = CONTROL_UI_PERFORMANCE_BUDGETS) { + const metrics = collectControlUiPerformanceMetrics(distDir); + return { + metrics, + budgets, + violations: evaluateControlUiPerformanceBudgets(metrics, budgets), + report: formatControlUiPerformanceReport(metrics, budgets), + }; +} + +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]}`); + } + const here = path.dirname(fileURLToPath(import.meta.url)); + const result = runControlUiPerformanceCheck(path.resolve(here, "../dist/control-ui")); + if (argv.includes("--json")) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } else { + process.stdout.write(`${result.report}\n`); + } + if (result.violations.length > 0) { + process.exitCode = 1; + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/test/scripts/control-ui-performance.test.ts b/test/scripts/control-ui-performance.test.ts new file mode 100644 index 000000000000..463ac622d0a7 --- /dev/null +++ b/test/scripts/control-ui-performance.test.ts @@ -0,0 +1,115 @@ +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, +} 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 }; +} + +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("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"); + }); +});