mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
* feat(ui): publish sidebar facts and open session actions for the native app Keep conversation contract 1 and negotiate sidebar facts/actions with host features. Forward the existing sidebar's document-bound, bounded draft/attention projection without composer or transcript content. Open the current web session menu through the existing navigation path; plugin execution and worker Stop stay with web owners. The chat header previously lacked sidebar plugin/Stop entries. Expose their existing owners only for negotiated native hosts. A quick menu selection confirms opening before its animation ends. Compact menu reset stays with the existing open hook. Follow-up: Control UI durable-draft badges after reload. The current sessionStorage projection does not enumerate IndexedDB drafts until their pane restores them (outbox-store-projection.ts; composer-draft-store.runtime.ts; durable-composer-persistence.ts). This deliberately preserves the web sidebar's current behavior on both surfaces. Validation: focused web tests, native-conversation E2E, Stop ordering regressions, UI/core/root typechecks, localization/performance tests, and P2 autoreview. * feat(macos): show web composer badges and session actions in the sidebar Render web-owned draft pencils and outbox attention counts for sidebar sessions. Retire facts and actions with the trusted document, and hide More actions for older UI documents. Reserve the clicked target before navigating to its web actions menu. Keep keyboard focus in that menu and leave plugin/worker execution with web owners. Align the native command waiter with the web's 15-second deadline; retain the separate 10-second document readiness timeout. Match web slash encoding when enforcing the snapshot byte limit, so valid snapshots do not retain stale badges. Quick Chat and iOS retain their existing Swift ownership and behavior. Validation: native/default app builds, macOS test compilation, focused Swift contract/row/menu and ownership/draft/outbox/route suites, localization checks, and P2 autoreview. Native app launch and full native tests remain with the coordinator's isolated live-proof rig; no push or publication. * fix(macos): reconcile sidebar bridge with current UI owners Preserve durable draft ownership, omit inactive badge rows, and memoize native action presentation without duplicating web execution. Reconcile native test waits and record the measured startup budget within the approved sub-1% allowance. * style(macos): format sidebar badge arguments for strict lint
752 lines
29 KiB
TypeScript
752 lines
29 KiB
TypeScript
#!/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";
|
|
import {
|
|
CONTROL_UI_ROUTE_PRELOAD_ATTRIBUTE,
|
|
selectControlUiRoutePreloads,
|
|
} from "../src/gateway/control-ui-route-preloads.ts";
|
|
import { reportLimitViolations } from "./lib/check-limits.mts";
|
|
import { CONTROL_UI_LOCALE_ENTRIES } from "./lib/control-ui-i18n-config.ts";
|
|
import { isRecord } from "./lib/record-shared.mjs";
|
|
import { escapeRegExp } from "./lib/regexp.mjs";
|
|
|
|
const KIB = 1024;
|
|
const STARTUP_JS_BASELINE_RATCHET_BYTES = 4096;
|
|
const BASELINE_UPDATE_COMMAND =
|
|
'node --import ./scripts/tsx.mjs scripts/check-control-ui-performance.mts --update-baseline --reason "<reason>"';
|
|
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.
|
|
const CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES = 512;
|
|
const CONTROL_UI_STARTUP_JS_GZIP_BUILD_VARIANCE_BYTES = 64;
|
|
const CONTROL_UI_STARTUP_CSS_GZIP_TARGET_BYTES = 45 * KIB;
|
|
// Immediate Home and diagnostic frames approved in #147574, including shared header styles.
|
|
const CONTROL_UI_CSS_GZIP_GROWTH_BYTES = 1.5 * KIB;
|
|
// The opaque Mermaid sandbox loads one self-contained classic script only when
|
|
// a diagram is viewed. Keep its size visible without relaxing ordinary chunks.
|
|
const MERMAID_RENDERER_ASSET = /^assets\/mermaid\.min-[\w-]+\.js$/u;
|
|
const MERMAID_RENDERER_GZIP_BYTES = 960 * KIB;
|
|
// Locale catalogs are named Vite chunks loaded only after a language selection.
|
|
// Base and config-hint chunks share one budget because they activate atomically.
|
|
const CONTROL_UI_LOCALE_ASSET_PATTERNS = CONTROL_UI_LOCALE_ENTRIES.flatMap(({ locale }) => [
|
|
{
|
|
locale,
|
|
kind: "base" as const,
|
|
pattern: new RegExp(`^assets/${escapeRegExp(locale)}-[^/]+\\.js$`, "u"),
|
|
},
|
|
{
|
|
locale,
|
|
kind: "configHints" as const,
|
|
pattern: new RegExp(`^assets/locale-config-hints-${escapeRegExp(locale)}-[^/]+\\.js$`, "u"),
|
|
},
|
|
]);
|
|
const CONTROL_UI_LOCALE_GZIP_BYTES = 300 * KIB;
|
|
|
|
// Small, explicit headroom over the optimized baseline. Budget changes should
|
|
// accompany an intentional loading or chunking decision.
|
|
const controlUiPerformanceBudgets = {
|
|
startupJsRequests: 18,
|
|
// Main 098173f9f5d4 with facade optimization measured chat/new at 31/32 requests.
|
|
// Allow 3 above the maximum while catching the roughly 19-request facade regression.
|
|
routeBootJsRequests: 35,
|
|
startupCssRequests: 1,
|
|
// Native sidebar bridge after main reconciliation; retain fixed growth and variance allowances.
|
|
startupJsGzipBytes: 372_878,
|
|
// Keep 45 KiB advisory: tiny integrated changes must not exhaust the budget.
|
|
// The fixed 50 KiB ceiling bounds accumulation of small changes.
|
|
startupCssGzipBytes: 50 * KIB,
|
|
largestJsGzipBytes: 215 * KIB,
|
|
// Composer multiline surface (stack #124301) legitimately grew boot CSS;
|
|
// operator decision 2026-08-25 rejected boot splitting due to precedence risk.
|
|
// 53.0 KiB was exhausted by organic growth (main sat at 99.94% by 2026-08-29);
|
|
// bumped to 53.5 KiB with operator approval on PR #132054. 2026-09-02: side
|
|
// panel, workboard chip, and Lobsterdex styles moved to their lazy owners,
|
|
// measured boot sheet 52,337 B; ceiling lowered to keep ~1 KiB headroom.
|
|
largestCssGzipBytes: 53_400,
|
|
} satisfies Record<string, number>;
|
|
export const CONTROL_UI_PERFORMANCE_BUDGETS = Object.freeze(controlUiPerformanceBudgets);
|
|
|
|
function controlUiAssetPathFromUrl(value: string): string | null {
|
|
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: string): string[] {
|
|
const assets = new Set<string>();
|
|
for (const tag of html.matchAll(/<(?:link|script)\b[^>]*>/giu)) {
|
|
const attribute = tag[0].match(/\s(?:href|src)\s*=\s*["']([^"']+)["']/iu);
|
|
const assetPath = attribute?.[1] ? controlUiAssetPathFromUrl(attribute[1]) : null;
|
|
if (assetPath) {
|
|
assets.add(assetPath);
|
|
}
|
|
}
|
|
return [...assets].toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
function readAssetMetrics(assetsDir: string, entry: fs.Dirent) {
|
|
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)}`);
|
|
}
|
|
}
|
|
const type = entry.name.endsWith(".js") ? "js" : "css";
|
|
return {
|
|
file,
|
|
type,
|
|
rawBytes: fs.statSync(sourcePath).size,
|
|
gzipBytes: fs.statSync(gzipPath).size,
|
|
brotliBytes: fs.statSync(brotliPath).size,
|
|
};
|
|
}
|
|
|
|
function summarizeAssets(assets: Array<ReturnType<typeof readAssetMetrics>>) {
|
|
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: Array<ReturnType<typeof readAssetMetrics>>) {
|
|
return assets.toSorted(
|
|
(left, right) => right.gzipBytes - left.gzipBytes || left.file.localeCompare(right.file),
|
|
)[0]!;
|
|
}
|
|
|
|
function controlUiLocaleAssetIdentity(
|
|
file: string,
|
|
): { locale: string; kind: "base" | "configHints" } | null {
|
|
const match = CONTROL_UI_LOCALE_ASSET_PATTERNS.find(({ pattern }) => pattern.test(file));
|
|
return match ? { locale: match.locale, kind: match.kind } : null;
|
|
}
|
|
|
|
function collectControlUiLocaleAssetGroups(assets: Array<ReturnType<typeof readAssetMetrics>>) {
|
|
const groups = new Map<
|
|
string,
|
|
{
|
|
locale: string;
|
|
base: Array<ReturnType<typeof readAssetMetrics>>;
|
|
configHints: Array<ReturnType<typeof readAssetMetrics>>;
|
|
}
|
|
>();
|
|
for (const asset of assets) {
|
|
const identity = controlUiLocaleAssetIdentity(asset.file);
|
|
if (!identity) {
|
|
continue;
|
|
}
|
|
const group = groups.get(identity.locale) ?? {
|
|
locale: identity.locale,
|
|
base: [],
|
|
configHints: [],
|
|
};
|
|
group[identity.kind].push(asset);
|
|
groups.set(identity.locale, group);
|
|
}
|
|
return [...groups.values()];
|
|
}
|
|
|
|
export function collectControlUiPerformanceMetrics(distDir: string) {
|
|
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 collectBootAssets = (document: string) => {
|
|
const bootAssets = extractControlUiStartupAssetPaths(document).map((file) => {
|
|
const asset = assetsByFile.get(file);
|
|
if (!asset) {
|
|
throw new Error(`Control UI performance check cannot find startup asset ${file}`);
|
|
}
|
|
return asset;
|
|
});
|
|
return {
|
|
js: summarizeAssets(bootAssets.filter((asset) => asset.type === "js")),
|
|
css: summarizeAssets(bootAssets.filter((asset) => asset.type === "css")),
|
|
assets: bootAssets,
|
|
};
|
|
};
|
|
// Preserve the historical entry budget; route preloads also account for the
|
|
// measured immediate dynamic imports that it never included.
|
|
const startup = collectBootAssets(selectControlUiRoutePreloads(html, null));
|
|
const routeBoot = html.includes(CONTROL_UI_ROUTE_PRELOAD_ATTRIBUTE)
|
|
? {
|
|
chat: collectBootAssets(selectControlUiRoutePreloads(html, "chat")),
|
|
new: collectBootAssets(selectControlUiRoutePreloads(html, "new")),
|
|
}
|
|
: null;
|
|
const jsAssets = assets.filter((asset) => asset.type === "js");
|
|
const mermaidRenderer = jsAssets.filter((asset) => MERMAID_RENDERER_ASSET.test(asset.file));
|
|
const localeCatalogs = jsAssets.filter(
|
|
(asset) => controlUiLocaleAssetIdentity(asset.file) !== null,
|
|
);
|
|
const ordinaryJsAssets = jsAssets.filter(
|
|
(asset) =>
|
|
!MERMAID_RENDERER_ASSET.test(asset.file) && controlUiLocaleAssetIdentity(asset.file) === null,
|
|
);
|
|
const cssAssets = assets.filter((asset) => asset.type === "css");
|
|
if (ordinaryJsAssets.length === 0 || cssAssets.length === 0 || startup.assets.length === 0) {
|
|
throw new Error("Control UI performance check found an incomplete production bundle");
|
|
}
|
|
return {
|
|
schemaVersion: 1 as const,
|
|
startup,
|
|
routeBoot,
|
|
total: {
|
|
js: summarizeAssets(jsAssets),
|
|
css: summarizeAssets(cssAssets),
|
|
},
|
|
largest: {
|
|
js: largestAsset(ordinaryJsAssets),
|
|
css: largestAsset(cssAssets),
|
|
},
|
|
mermaidRenderer,
|
|
localeCatalogs,
|
|
};
|
|
}
|
|
|
|
export function evaluateControlUiPerformanceBudgets(
|
|
metrics: ReturnType<typeof collectControlUiPerformanceMetrics>,
|
|
budgets: Readonly<typeof CONTROL_UI_PERFORMANCE_BUDGETS> = CONTROL_UI_PERFORMANCE_BUDGETS,
|
|
startupBudgetBaseline: Readonly<ControlUiStartupBudgetBaseline> | null = null,
|
|
startupJsTolerance = CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES,
|
|
baseMetrics: ReturnType<typeof collectControlUiPerformanceMetrics> | null = null,
|
|
) {
|
|
const baselineBytes = startupBudgetBaseline?.startupJsGzipBytes;
|
|
const startupJsLimits = resolveControlUiStartupJsGzipLimits(
|
|
budgets,
|
|
startupBudgetBaseline,
|
|
startupJsTolerance,
|
|
);
|
|
const localeGroups = collectControlUiLocaleAssetGroups(metrics.localeCatalogs);
|
|
const startupAssetFiles = new Set(
|
|
[
|
|
...metrics.startup.assets,
|
|
...Object.values(metrics.routeBoot ?? {}).flatMap((route) => route.assets),
|
|
].map((asset) => asset.file),
|
|
);
|
|
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, startupJsLimits.enforcementLimit, "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"],
|
|
["isolated Mermaid JS assets", metrics.mermaidRenderer.length, 1, "count"],
|
|
[
|
|
"isolated Mermaid JS gzip",
|
|
summarizeAssets(metrics.mermaidRenderer).gzipBytes,
|
|
MERMAID_RENDERER_GZIP_BYTES,
|
|
"bytes",
|
|
],
|
|
[
|
|
"startup Mermaid JS assets",
|
|
[...startupAssetFiles].filter((file) => MERMAID_RENDERER_ASSET.test(file)).length,
|
|
0,
|
|
"count",
|
|
],
|
|
[
|
|
"locale catalog JS assets",
|
|
metrics.localeCatalogs.length,
|
|
CONTROL_UI_LOCALE_ENTRIES.length * 2,
|
|
"count",
|
|
],
|
|
[
|
|
"locale catalog base JS assets per locale",
|
|
Math.max(0, ...localeGroups.map((group) => group.base.length)),
|
|
1,
|
|
"count",
|
|
],
|
|
[
|
|
"locale config-hint JS assets per locale",
|
|
Math.max(0, ...localeGroups.map((group) => group.configHints.length)),
|
|
1,
|
|
"count",
|
|
],
|
|
[
|
|
"largest locale catalog pair JS gzip",
|
|
Math.max(
|
|
0,
|
|
...localeGroups.map(
|
|
(group) => summarizeAssets([...group.base, ...group.configHints]).gzipBytes,
|
|
),
|
|
),
|
|
CONTROL_UI_LOCALE_GZIP_BYTES,
|
|
"bytes",
|
|
],
|
|
[
|
|
"startup locale catalog JS assets",
|
|
[...startupAssetFiles].filter((file) => controlUiLocaleAssetIdentity(file) !== null).length,
|
|
0,
|
|
"count",
|
|
],
|
|
];
|
|
if (metrics.routeBoot) {
|
|
for (const route of ["chat", "new"] as const) {
|
|
checks.push([
|
|
`${route} boot JS requests`,
|
|
metrics.routeBoot[route].js.requests,
|
|
budgets.routeBootJsRequests,
|
|
"count",
|
|
]);
|
|
}
|
|
}
|
|
const violations = checks.flatMap(([metric, actual, limit, unit]) =>
|
|
actual > limit ? [{ metric, actual, limit, unit }] : [],
|
|
);
|
|
if (baseMetrics) {
|
|
for (const area of ["startup", "largest"] as const) {
|
|
const growth = metrics[area].css.gzipBytes - baseMetrics[area].css.gzipBytes;
|
|
if (growth >= CONTROL_UI_CSS_GZIP_GROWTH_BYTES) {
|
|
violations.push({
|
|
metric: `${area} CSS gzip growth`,
|
|
actual: growth,
|
|
limit: CONTROL_UI_CSS_GZIP_GROWTH_BYTES - 1,
|
|
unit: "bytes",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
if (baselineBytes !== undefined && baselineBytes > budgets.startupJsGzipBytes) {
|
|
violations.unshift({
|
|
metric: "startup JS gzip baseline",
|
|
actual: baselineBytes,
|
|
limit: budgets.startupJsGzipBytes,
|
|
unit: "bytes",
|
|
});
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
function resolveControlUiStartupJsGzipLimits(
|
|
budgets: Readonly<typeof CONTROL_UI_PERFORMANCE_BUDGETS>,
|
|
startupBudgetBaseline: Readonly<ControlUiStartupBudgetBaseline> | null,
|
|
startupJsTolerance: number,
|
|
) {
|
|
const baselineCap = budgets.startupJsGzipBytes;
|
|
if (!startupBudgetBaseline) {
|
|
return {
|
|
baselineCap,
|
|
growthLimit: baselineCap,
|
|
buildVariance: 0,
|
|
enforcementLimit: baselineCap,
|
|
};
|
|
}
|
|
const growthLimit =
|
|
Math.min(startupBudgetBaseline.startupJsGzipBytes, baselineCap) + startupJsTolerance;
|
|
return {
|
|
baselineCap,
|
|
growthLimit,
|
|
buildVariance: CONTROL_UI_STARTUP_JS_GZIP_BUILD_VARIANCE_BYTES,
|
|
enforcementLimit: growthLimit + CONTROL_UI_STARTUP_JS_GZIP_BUILD_VARIANCE_BYTES,
|
|
};
|
|
}
|
|
|
|
type ControlUiPerformanceBudgetViolation = ReturnType<
|
|
typeof evaluateControlUiPerformanceBudgets
|
|
>[number];
|
|
|
|
function formatControlUiPerformanceBytes(bytes: number): string {
|
|
return bytes < KIB ? `${bytes} B` : `${(bytes / KIB).toFixed(1)} KiB`;
|
|
}
|
|
|
|
function formatRequestCount(count: number): string {
|
|
return `${count} ${count === 1 ? "request" : "requests"}`;
|
|
}
|
|
|
|
function formatAssetSummary(summary: ReturnType<typeof summarizeAssets>): string {
|
|
return `${formatRequestCount(summary.requests)}, ${formatControlUiPerformanceBytes(summary.gzipBytes)} gzip (${summary.gzipBytes} B), ${formatControlUiPerformanceBytes(summary.brotliBytes)} br, ${summary.rawBytes} B raw`;
|
|
}
|
|
|
|
function controlUiPerformanceWarnings(
|
|
metrics: ReturnType<typeof collectControlUiPerformanceMetrics>,
|
|
budgets: Readonly<typeof CONTROL_UI_PERFORMANCE_BUDGETS>,
|
|
): string[] {
|
|
const warnings: string[] = [];
|
|
if (metrics.startup.css.gzipBytes > CONTROL_UI_STARTUP_CSS_GZIP_TARGET_BYTES) {
|
|
warnings.push(
|
|
`startup CSS gzip is above the ${CONTROL_UI_STARTUP_CSS_GZIP_TARGET_BYTES} B advisory target; remaining hard-ceiling headroom: ${budgets.startupCssGzipBytes - metrics.startup.css.gzipBytes} B`,
|
|
);
|
|
}
|
|
const largestCssHeadroom = budgets.largestCssGzipBytes - metrics.largest.css.gzipBytes;
|
|
if (largestCssHeadroom < CONTROL_UI_CSS_GZIP_GROWTH_BYTES) {
|
|
warnings.push(`largest CSS gzip has ${largestCssHeadroom} B of hard-ceiling headroom`);
|
|
}
|
|
return warnings;
|
|
}
|
|
|
|
function formatViolation(violation: ControlUiPerformanceBudgetViolation): string {
|
|
const format = violation.unit === "bytes" ? formatControlUiPerformanceBytes : String;
|
|
const actual = format(violation.actual);
|
|
const limit = format(violation.limit);
|
|
const exactBytes =
|
|
violation.unit === "bytes" && actual === limit
|
|
? ` (${violation.actual} B vs ${violation.limit} B)`
|
|
: "";
|
|
return `${violation.metric}: ${actual} exceeds ${limit}${exactBytes}`;
|
|
}
|
|
|
|
export function formatControlUiPerformanceReport(
|
|
metrics: ReturnType<typeof collectControlUiPerformanceMetrics>,
|
|
budgets: Readonly<typeof CONTROL_UI_PERFORMANCE_BUDGETS> = CONTROL_UI_PERFORMANCE_BUDGETS,
|
|
startupBudgetBaseline: Readonly<ControlUiStartupBudgetBaseline> | null = null,
|
|
startupJsTolerance: number = CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES,
|
|
baseMetrics: ReturnType<typeof collectControlUiPerformanceMetrics> | null = null,
|
|
): string {
|
|
const startupJsLimits = resolveControlUiStartupJsGzipLimits(
|
|
budgets,
|
|
startupBudgetBaseline,
|
|
startupJsTolerance,
|
|
);
|
|
const violations = evaluateControlUiPerformanceBudgets(
|
|
metrics,
|
|
budgets,
|
|
startupBudgetBaseline,
|
|
startupJsTolerance,
|
|
baseMetrics,
|
|
);
|
|
const lines = [
|
|
"Control UI performance:",
|
|
` measurement: shipped gzip sidecars; Node ${process.version}`,
|
|
` startup JS: ${formatAssetSummary(metrics.startup.js)} (limits: ${formatRequestCount(budgets.startupJsRequests)}, ${formatControlUiPerformanceBytes(startupJsLimits.enforcementLimit)} gzip / ${startupJsLimits.enforcementLimit} B)`,
|
|
];
|
|
if (startupBudgetBaseline) {
|
|
lines.push(
|
|
` startup JS gzip vs baseline: ${metrics.startup.js.gzipBytes} B (baseline ${startupBudgetBaseline.startupJsGzipBytes} B + growth allowance ${startupJsTolerance} B = growth limit ${startupJsLimits.growthLimit} B; build-variance allowance ${startupJsLimits.buildVariance} B; enforcement limit ${startupJsLimits.enforcementLimit} B; max committed baseline ${startupJsLimits.baselineCap} B)`,
|
|
);
|
|
}
|
|
lines.push(
|
|
` startup CSS: ${formatAssetSummary(metrics.startup.css)} (limits: ${formatRequestCount(budgets.startupCssRequests)}, advisory target ${CONTROL_UI_STARTUP_CSS_GZIP_TARGET_BYTES} B, hard ceiling ${budgets.startupCssGzipBytes} B; headroom ${budgets.startupCssGzipBytes - metrics.startup.css.gzipBytes} B)`,
|
|
` largest ordinary JS: ${metrics.largest.js.file}, ${formatControlUiPerformanceBytes(metrics.largest.js.gzipBytes)} gzip (limit: ${formatControlUiPerformanceBytes(budgets.largestJsGzipBytes)})`,
|
|
` largest CSS: ${metrics.largest.css.file}, ${metrics.largest.css.gzipBytes} B gzip (hard ceiling ${budgets.largestCssGzipBytes} B; headroom ${budgets.largestCssGzipBytes - metrics.largest.css.gzipBytes} B)`,
|
|
` all JS: ${formatAssetSummary(metrics.total.js)}`,
|
|
` all CSS: ${formatAssetSummary(metrics.total.css)}`,
|
|
);
|
|
if (metrics.routeBoot) {
|
|
lines.push(
|
|
" route boot accounting: initial-entry assets plus measured immediate dynamic imports; existing initial-entry limits unchanged",
|
|
);
|
|
for (const route of ["chat", "new"] as const) {
|
|
const boot = metrics.routeBoot[route];
|
|
lines.push(
|
|
` ${route} boot JS: ${formatAssetSummary(boot.js)} (${boot.js.gzipBytes - metrics.startup.js.gzipBytes} B beyond initial-entry JS; limit: ${formatRequestCount(budgets.routeBootJsRequests)})`,
|
|
` ${route} boot CSS: ${formatAssetSummary(boot.css)}`,
|
|
);
|
|
if (baseMetrics) {
|
|
const baseBoot = baseMetrics.routeBoot?.[route];
|
|
if (baseBoot) {
|
|
for (const type of ["js", "css"] as const) {
|
|
const growth = boot[type].gzipBytes - baseBoot[type].gzipBytes;
|
|
lines.push(
|
|
` ${route} boot ${type.toUpperCase()} gzip vs base: ${baseBoot[type].gzipBytes} B -> ${boot[type].gzipBytes} B (${growth >= 0 ? "+" : ""}${growth} B); requests ${baseBoot[type].requests} -> ${boot[type].requests}`,
|
|
);
|
|
}
|
|
} else {
|
|
lines.push(` ${route} boot vs base: unavailable (base has no route preload templates)`);
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
lines.push(" route boot accounting: unavailable (build has no route preload templates)");
|
|
}
|
|
if (baseMetrics) {
|
|
for (const area of ["startup", "largest"] as const) {
|
|
const growth = metrics[area].css.gzipBytes - baseMetrics[area].css.gzipBytes;
|
|
lines.push(
|
|
` ${area} CSS gzip vs base: ${baseMetrics[area].css.gzipBytes} B -> ${metrics[area].css.gzipBytes} B (${growth >= 0 ? "+" : ""}${growth} B; growth below ${CONTROL_UI_CSS_GZIP_GROWTH_BYTES} B allowed)`,
|
|
);
|
|
}
|
|
}
|
|
for (const warning of controlUiPerformanceWarnings(metrics, budgets)) {
|
|
lines.push(` warning: ${warning}`);
|
|
}
|
|
if (metrics.mermaidRenderer.length > 0) {
|
|
lines.push(
|
|
` isolated Mermaid JS: ${formatAssetSummary(summarizeAssets(metrics.mermaidRenderer))} (limits: 1 deferred asset, ${formatControlUiPerformanceBytes(MERMAID_RENDERER_GZIP_BYTES)} gzip; forbidden at startup)`,
|
|
);
|
|
}
|
|
if (metrics.localeCatalogs.length > 0) {
|
|
const localeGroups = collectControlUiLocaleAssetGroups(metrics.localeCatalogs);
|
|
const largestLocalePair = localeGroups.toSorted(
|
|
(left, right) =>
|
|
summarizeAssets([...right.base, ...right.configHints]).gzipBytes -
|
|
summarizeAssets([...left.base, ...left.configHints]).gzipBytes ||
|
|
left.locale.localeCompare(right.locale),
|
|
)[0]!;
|
|
const largestLocalePairSummary = summarizeAssets([
|
|
...largestLocalePair.base,
|
|
...largestLocalePair.configHints,
|
|
]);
|
|
lines.push(
|
|
` locale catalog JS: ${formatAssetSummary(summarizeAssets(metrics.localeCatalogs))} (largest pair: ${largestLocalePair.locale}, ${formatControlUiPerformanceBytes(largestLocalePairSummary.gzipBytes)} gzip; limits: 1 base + 1 config-hint asset per locale, ${formatControlUiPerformanceBytes(CONTROL_UI_LOCALE_GZIP_BYTES)} combined; forbidden at startup)`,
|
|
);
|
|
}
|
|
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 ${BASELINE_UPDATE_COMMAND}`,
|
|
);
|
|
}
|
|
if (violations.length > 0) {
|
|
lines.push(
|
|
" violations:",
|
|
...violations.map((violation) => ` - ${formatViolation(violation)}`),
|
|
);
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function isIsoDate(value: string): boolean {
|
|
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: string): ControlUiStartupBudgetBaseline {
|
|
try {
|
|
const parsed: unknown = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
|
|
const record: Record<string, unknown> = isRecord(parsed) ? parsed : {};
|
|
const { startupJsGzipBytes, reason, updatedAt } = record;
|
|
if (
|
|
typeof startupJsGzipBytes !== "number" ||
|
|
!Number.isSafeInteger(startupJsGzipBytes) ||
|
|
startupJsGzipBytes < 0 ||
|
|
typeof reason !== "string" ||
|
|
reason.trim().length === 0 ||
|
|
typeof updatedAt !== "string" ||
|
|
!isIsoDate(updatedAt)
|
|
) {
|
|
throw new Error(
|
|
"expected non-negative integer startupJsGzipBytes, non-empty reason, and YYYY-MM-DD updatedAt",
|
|
);
|
|
}
|
|
return { startupJsGzipBytes, reason, 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 ${BASELINE_UPDATE_COMMAND}.`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
}
|
|
|
|
function writeControlUiStartupBudgetBaseline(
|
|
baselinePath: string,
|
|
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,
|
|
updatedAt: new Date().toISOString().slice(0, 10),
|
|
};
|
|
fs.writeFileSync(baselinePath, `${JSON.stringify(baseline, null, 2)}\n`);
|
|
return baseline;
|
|
}
|
|
|
|
type ControlUiStartupBudgetBaseline = ReturnType<typeof writeControlUiStartupBudgetBaseline>;
|
|
|
|
function validateExplicitStartupJsBytes(
|
|
startupJsBytes: number,
|
|
currentBaseline: ControlUiStartupBudgetBaseline,
|
|
): void {
|
|
const delta = Math.abs(startupJsBytes - currentBaseline.startupJsGzipBytes);
|
|
if (delta > STARTUP_JS_BASELINE_RATCHET_BYTES) {
|
|
throw new Error(
|
|
`startup JS gzip baseline update: ${startupJsBytes} B differs from current baseline ${currentBaseline.startupJsGzipBytes} B by ${delta} B, exceeding the ${STARTUP_JS_BASELINE_RATCHET_BYTES} B ratchet`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function runControlUiPerformanceCheck(
|
|
distDir: string,
|
|
budgets: Readonly<typeof CONTROL_UI_PERFORMANCE_BUDGETS> = CONTROL_UI_PERFORMANCE_BUDGETS,
|
|
baselinePath = DEFAULT_STARTUP_BUDGET_BASELINE_PATH,
|
|
baseDistDir?: string,
|
|
) {
|
|
const startupBudgetBaseline = readControlUiStartupBudgetBaseline(baselinePath);
|
|
const metrics = collectControlUiPerformanceMetrics(distDir);
|
|
const baseMetrics = baseDistDir ? collectControlUiPerformanceMetrics(baseDistDir) : null;
|
|
const violations = evaluateControlUiPerformanceBudgets(
|
|
metrics,
|
|
budgets,
|
|
startupBudgetBaseline,
|
|
undefined,
|
|
baseMetrics,
|
|
);
|
|
const report = formatControlUiPerformanceReport(
|
|
metrics,
|
|
budgets,
|
|
startupBudgetBaseline,
|
|
undefined,
|
|
baseMetrics,
|
|
);
|
|
return {
|
|
metrics,
|
|
baseMetrics,
|
|
budgets,
|
|
startupBudgetBaseline,
|
|
startupJsTolerance: CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES,
|
|
startupJsBuildVariance: CONTROL_UI_STARTUP_JS_GZIP_BUILD_VARIANCE_BYTES,
|
|
violations,
|
|
warnings: controlUiPerformanceWarnings(metrics, budgets),
|
|
report,
|
|
};
|
|
}
|
|
|
|
function main(argv: string[] = process.argv.slice(2)): void {
|
|
let json = false;
|
|
let reportOnly = false;
|
|
let distDir: string | undefined;
|
|
let baseDistDir: string | undefined;
|
|
let updateBaseline = false;
|
|
let reason: string | undefined;
|
|
let startupJsBytes: number | undefined;
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--json") {
|
|
json = true;
|
|
} else if (arg === "--report-only") {
|
|
reportOnly = true;
|
|
} else if (arg === "--base-dist" || arg === "--dist") {
|
|
const value = argv[++index];
|
|
if (!value || value.startsWith("--")) {
|
|
throw new Error(`${arg} requires a directory`);
|
|
}
|
|
if (arg === "--dist") {
|
|
distDir = path.resolve(value);
|
|
} else {
|
|
baseDistDir = path.resolve(value);
|
|
}
|
|
} 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 if (arg === "--startup-js-bytes") {
|
|
const value = argv[index + 1];
|
|
if (!value || !/^[1-9]\d*$/u.test(value)) {
|
|
throw new Error("--startup-js-bytes requires a positive integer");
|
|
}
|
|
startupJsBytes = Number(value);
|
|
if (!Number.isSafeInteger(startupJsBytes)) {
|
|
throw new Error("--startup-js-bytes requires a positive integer");
|
|
}
|
|
index += 1;
|
|
} else {
|
|
throw new Error(`Unknown option: ${arg}`);
|
|
}
|
|
}
|
|
if (reason !== undefined && !updateBaseline) {
|
|
throw new Error("--reason requires --update-baseline");
|
|
}
|
|
if (startupJsBytes !== undefined && !updateBaseline) {
|
|
throw new Error("--startup-js-bytes requires --update-baseline");
|
|
}
|
|
if (json && updateBaseline) {
|
|
throw new Error("--json cannot be combined with --update-baseline");
|
|
}
|
|
if (updateBaseline && (reportOnly || baseDistDir || distDir)) {
|
|
throw new Error(
|
|
"--report-only, --base-dist and --dist cannot be combined with --update-baseline",
|
|
);
|
|
}
|
|
distDir ??= path.resolve(SCRIPT_DIR, "../dist/control-ui");
|
|
if (updateBaseline) {
|
|
if (startupJsBytes !== undefined) {
|
|
const currentBaseline = readControlUiStartupBudgetBaseline(
|
|
DEFAULT_STARTUP_BUDGET_BASELINE_PATH,
|
|
);
|
|
validateExplicitStartupJsBytes(startupJsBytes, currentBaseline);
|
|
}
|
|
const nextStartupJsBytes =
|
|
startupJsBytes ?? collectControlUiPerformanceMetrics(distDir).startup.js.gzipBytes;
|
|
const baseline = writeControlUiStartupBudgetBaseline(
|
|
DEFAULT_STARTUP_BUDGET_BASELINE_PATH,
|
|
nextStartupJsBytes,
|
|
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, undefined, undefined, baseDistDir);
|
|
if (json) {
|
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
} else {
|
|
process.stdout.write(`${result.report}\n`);
|
|
}
|
|
if (!reportOnly) {
|
|
const artifactContractMetrics = new Set([
|
|
"isolated Mermaid JS assets",
|
|
"startup Mermaid JS assets",
|
|
"locale catalog base JS assets per locale",
|
|
"locale config-hint JS assets per locale",
|
|
"startup locale catalog JS assets",
|
|
]);
|
|
const limitsFailed = reportLimitViolations(
|
|
result.violations
|
|
.filter((violation) => !artifactContractMetrics.has(violation.metric))
|
|
.map((violation) => ({
|
|
file:
|
|
violation.metric === "startup JS gzip baseline"
|
|
? "config/control-ui-startup-budget-baseline.json"
|
|
: "scripts/check-control-ui-performance.mts",
|
|
title: "Control UI asset budget",
|
|
message: formatViolation(violation),
|
|
})),
|
|
);
|
|
if (
|
|
limitsFailed ||
|
|
result.violations.some((violation) => artifactContractMetrics.has(violation.metric))
|
|
) {
|
|
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;
|
|
}
|
|
}
|