fix(ci): adapt gzip-budget check for the @angular/localize multi-bundle layout
The original commit on this branch (9ed74ca, then cherry-picked as
f776ce7) predated ADR-0019 / PR #91, which switched the portal-shell
build to `@angular/localize` and started emitting one self-contained
bundle per locale under `dist/apps/portal-shell/browser/<locale>/`.
The script looked for `index.html` directly under the dist root and
failed with ENOENT on every modern build.
Fix
- `scripts/check-gzip-budgets.mjs`:
- Auto-detects layout: if `dist/index.html` exists → flat mode
(unchanged behaviour). Otherwise enumerate immediate
subdirectories with their own `index.html` and run the check
per locale.
- Budget thresholds apply *per locale* — each bundle is what the
user's browser actually downloads. Aggregating across locales
would understate the worst case.
- Violations across locales are collected and reported together
so a single CI run surfaces every breach; the locale tag is
prefixed to each violation message for clarity.
- `docs/decisions/0017-...md` §Confirmation: spell out the per-locale
check + cite ADR-0019 so a future reader of either ADR sees the
connection.
Verified locally on the production build:
- 2 locale bundles detected (en, fr).
- Initial JS total: ~141 KB / 300 KB budget ✓ (each locale).
- CSS total: 4.62 KB / 150 KB budget ✓ (each locale).
- Largest lazy chunk: 1.55 KB / 100 KB per-chunk budget ✓.
This commit is contained in:
@@ -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/<locale>/`, 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.
|
||||
|
||||
+108
-19
@@ -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/<locale>/`. 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) => {
|
||||
|
||||
Reference in New Issue
Block a user