diff --git a/docs/decisions/0017-performance-budgets-lighthouse-ci.md b/docs/decisions/0017-performance-budgets-lighthouse-ci.md index 41183a0..5c45158 100644 --- a/docs/decisions/0017-performance-budgets-lighthouse-ci.md +++ b/docs/decisions/0017-performance-budgets-lighthouse-ci.md @@ -210,7 +210,7 @@ No browser-side RUM SDK in v1. The OTel browser tracing from [ADR-0012](0012-obs - `lighthouserc.js` exists at the repo root with the critical-routes list, the assertions matching the thresholds above, and a 3-iteration median configuration. - `apps/portal-shell/project.json` declares `budgets` with `maximumWarning == maximumError` (so an overshoot fails the build, matching `type: "error"` in spirit). Four entries: `initial` (≤ 1 MB raw), `anyScript` (≤ 300 KB raw), `anyComponentStyle` (≤ 6 KB raw, with 5 KB warning floor), `bundle name=styles` (≤ 150 KB raw). Angular CLI compares **raw** sizes; the values above are translated from the gzip-based ADR targets at the conventional ≈ 3× JS / ≈ 4× CSS compression ratios. -- A complementary CI check in [`scripts/check-gzip-budgets.mjs`](../../scripts/check-gzip-budgets.mjs) (wired into `pnpm ci:perf` between the production build and Lighthouse) asserts the **actual** gzipped transfer size against the ADR thresholds: initial-JS-total ≤ 300 KB, any single lazy chunk ≤ 100 KB, total CSS ≤ 150 KB. Angular CLI does not natively support a gzip-mode budget — this script is the explicit gate. It parses `index.html` to distinguish initial assets from lazy chunks, gzips each file at level 9 (matching what HTTP servers typically serve), and exits non-zero on any breach. +- A complementary CI check in [`scripts/check-gzip-budgets.mjs`](../../scripts/check-gzip-budgets.mjs) (wired into `pnpm ci:perf` between the production build and Lighthouse) asserts the **actual** gzipped transfer size against the ADR thresholds: initial-JS-total ≤ 300 KB, any single lazy chunk ≤ 100 KB, total CSS ≤ 150 KB. Angular CLI does not natively support a gzip-mode budget — this script is the explicit gate. It parses `index.html` to distinguish initial assets from lazy chunks, gzips each file at level 9 (matching what HTTP servers typically serve), and exits non-zero on any breach. The check is **per locale bundle** — `@angular/localize` (per [ADR-0019](0019-internationalisation-angular-localize.md)) emits one self-contained bundle under `dist/.../browser//`, and each is what the user's browser actually downloads, so each is checked against the same budget independently. The script auto-detects locale-split vs flat layouts. - `package.json` exposes `ci:perf`, runnable locally with the same exit code as CI. - CI's `perf` gate from [ADR-0015](0015-cicd-gitea-actions.md) calls `pnpm ci:perf`. It is blocking on human PRs and on `push` to `main`; skipped on PRs authored by the Renovate bot user (`apf-portal-bot`) per the gating-policy subsection above. - `apps/portal-shell` exposes a Nx target `analyze` invoking `source-map-explorer` against the production build's source maps. diff --git a/scripts/check-gzip-budgets.mjs b/scripts/check-gzip-budgets.mjs index 3a44606..b0b8e06 100644 --- a/scripts/check-gzip-budgets.mjs +++ b/scripts/check-gzip-budgets.mjs @@ -19,11 +19,22 @@ * - sum of all CSS ≤ BUDGET_CSS_TOTAL * Exit non-zero on any violation. * + * Locale-split builds (ADR-0019) + * ------------------------------ + * `@angular/localize` emits one self-contained bundle per locale under + * `dist/apps/portal-shell/browser//`. The script auto-detects + * the layout: if the root has no `index.html` but immediate + * subdirectories do, each subdirectory is checked against the budgets + * separately. Budget thresholds apply *per locale* — each bundle is + * what the user's browser actually downloads. Violations across + * locales are aggregated and reported together so a single CI run + * surfaces every breach. + * * Invocation: `node scripts/check-gzip-budgets.mjs [dist-dir]`. * The default `dist-dir` is `dist/apps/portal-shell/browser`. */ -import { readdir, readFile } from 'node:fs/promises'; +import { readdir, readFile, stat } from 'node:fs/promises'; import { gzipSync } from 'node:zlib'; import { basename, join } from 'node:path'; @@ -36,22 +47,36 @@ const BUDGET_LAZY_JS_EACH = 100 * 1024; const BUDGET_CSS_TOTAL = 150 * 1024; const fmtKb = (n) => `${(n / 1024).toFixed(2)} KB`; - const padFile = (s) => s.padEnd(36); async function gzipSize(path) { return gzipSync(await readFile(path), { level: 9 }).byteLength; } -async function main() { +async function fileExists(path) { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +/** + * Check one Angular build directory (a single `index.html` and its + * sibling JS/CSS assets) against the ADR-0017 budgets. Returns the + * violations found — empty array means the bundle passed. + * + * The header passed in is rendered above the per-file table so the + * multi-locale invocation can prefix each section with the locale + * tag. + */ +async function checkBundle(bundleDir, header) { let indexHtml; try { - indexHtml = await readFile(join(dist, 'index.html'), 'utf8'); + indexHtml = await readFile(join(bundleDir, 'index.html'), 'utf8'); } catch (err) { - console.error(`✗ Could not read index.html under "${dist}".`); - console.error(` Did you run "nx build portal-shell --configuration=production" first?`); - console.error(` Underlying error: ${err.message}`); - process.exit(2); + throw new Error(`could not read ${join(bundleDir, 'index.html')}: ${err.message}`); } // Match every src=/href= that points at a .js or .css file in the @@ -63,7 +88,7 @@ async function main() { ), ); - const all = await readdir(dist); + const all = await readdir(bundleDir); const jsFiles = all.filter((f) => f.endsWith('.js')); const cssFiles = all.filter((f) => f.endsWith('.css')); @@ -71,7 +96,7 @@ async function main() { const lazyJs = jsFiles.filter((f) => !referenced.has(f)); const sized = async (files) => - Promise.all(files.map(async (f) => ({ f, size: await gzipSize(join(dist, f)) }))); + Promise.all(files.map(async (f) => ({ f, size: await gzipSize(join(bundleDir, f)) }))); const initialJsSizes = await sized(initialJs); const lazyJsSizes = await sized(lazyJs); @@ -83,20 +108,19 @@ async function main() { const violations = []; - console.log(`Gzip-transfer budget check (ADR-0017) — output: ${dist}\n`); + console.log(header); console.log(' Initial JS (loaded on first paint):'); for (const { f, size } of initialJsSizes.sort((a, b) => b.size - a.size)) { console.log(` ${padFile(f)} ${fmtKb(size)}`); } - const initialFlag = - totalInitialJs > BUDGET_INITIAL_JS_TOTAL ? ' ✗ EXCEEDS BUDGET' : ' ✓'; + const initialFlag = totalInitialJs > BUDGET_INITIAL_JS_TOTAL ? ' ✗ EXCEEDS BUDGET' : ' ✓'; console.log( ` ${padFile('TOTAL')} ${fmtKb(totalInitialJs)} / ${fmtKb(BUDGET_INITIAL_JS_TOTAL)}${initialFlag}`, ); if (totalInitialJs > BUDGET_INITIAL_JS_TOTAL) { violations.push( - `initial JS total ${fmtKb(totalInitialJs)} > budget ${fmtKb(BUDGET_INITIAL_JS_TOTAL)}`, + `${header.trim()}: initial JS total ${fmtKb(totalInitialJs)} > budget ${fmtKb(BUDGET_INITIAL_JS_TOTAL)}`, ); } @@ -108,7 +132,9 @@ async function main() { const flag = size > BUDGET_LAZY_JS_EACH ? ' ✗ EXCEEDS BUDGET' : ''; console.log(` ${padFile(f)} ${fmtKb(size)}${flag}`); if (size > BUDGET_LAZY_JS_EACH) { - violations.push(`lazy chunk ${f} ${fmtKb(size)} > per-chunk budget ${fmtKb(BUDGET_LAZY_JS_EACH)}`); + violations.push( + `${header.trim()}: lazy chunk ${f} ${fmtKb(size)} > per-chunk budget ${fmtKb(BUDGET_LAZY_JS_EACH)}`, + ); } } console.log(` (per-chunk budget: ${fmtKb(BUDGET_LAZY_JS_EACH)})`); @@ -119,19 +145,82 @@ async function main() { } const cssFlag = totalCss > BUDGET_CSS_TOTAL ? ' ✗ EXCEEDS BUDGET' : ' ✓'; console.log( - ` ${padFile('TOTAL')} ${fmtKb(totalCss)} / ${fmtKb(BUDGET_CSS_TOTAL)}${cssFlag}`, + ` ${padFile('TOTAL')} ${fmtKb(totalCss)} / ${fmtKb(BUDGET_CSS_TOTAL)}${cssFlag}\n`, ); if (totalCss > BUDGET_CSS_TOTAL) { - violations.push(`CSS total ${fmtKb(totalCss)} > budget ${fmtKb(BUDGET_CSS_TOTAL)}`); + violations.push( + `${header.trim()}: CSS total ${fmtKb(totalCss)} > budget ${fmtKb(BUDGET_CSS_TOTAL)}`, + ); + } + + return violations; +} + +/** + * Resolve which directories to check. A locale-split build has no + * `index.html` at the root but does have one in each immediate + * subdirectory (ADR-0019 §"`@angular/localize` build-time mode"). + * Returns either `[dist]` (flat layout) or `[dist/fr, dist/en, …]` + * (locale split, sorted alphabetically for deterministic output). + */ +async function resolveBundles(root) { + if (await fileExists(join(root, 'index.html'))) { + return [{ dir: root, label: root }]; + } + let entries; + try { + entries = await readdir(root, { withFileTypes: true }); + } catch (err) { + throw new Error(`could not read ${root}: ${err.message}`); + } + const subdirsWithIndex = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const candidate = join(root, entry.name); + if (await fileExists(join(candidate, 'index.html'))) { + subdirsWithIndex.push({ dir: candidate, label: entry.name }); + } + } + if (subdirsWithIndex.length === 0) { + throw new Error( + `no index.html in ${root} and no locale subdirectory contains one — ` + + `did you run "nx build portal-shell --configuration=production" first?`, + ); + } + subdirsWithIndex.sort((a, b) => a.label.localeCompare(b.label)); + return subdirsWithIndex; +} + +async function main() { + let bundles; + try { + bundles = await resolveBundles(dist); + } catch (err) { + console.error(`✗ ${err.message}`); + process.exit(2); + } + + const multi = bundles.length > 1; + console.log( + `Gzip-transfer budget check (ADR-0017) — output: ${dist}` + + (multi ? ` (${bundles.length} locale bundles)` : '') + + '\n', + ); + + const violations = []; + for (const { dir, label } of bundles) { + const header = multi ? `[locale: ${label}]\n` : ''; + const found = await checkBundle(dir, header); + violations.push(...found); } if (violations.length > 0) { - console.error('\n✗ ADR-0017 budget violations:'); + console.error('✗ ADR-0017 budget violations:'); for (const v of violations) console.error(` - ${v}`); process.exit(1); } - console.log('\n✓ All ADR-0017 gzip-transfer budgets respected.'); + console.log('✓ All ADR-0017 gzip-transfer budgets respected.'); } main().catch((err) => {