From 88cd1a672322e8e82e829f463f3be26023f56faf Mon Sep 17 00:00:00 2001 From: WebPerson Date: Thu, 1 Oct 2026 09:14:28 -0500 Subject: [PATCH] fix(ci): bundle-size ratchet no longer passes when it cannot measure (#15159 G-04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check-bundle-size.mjs --ratchet` printed `bundleSize=SKIP reason=…` and exited **0** on five separate "could not measure" paths, each with a comment saying so on purpose — e.g. :252 "SKIP sai 0 mesmo com --ratchet (erro de medição nunca bloqueia)". The ci.yml step is labelled "Bundle size (ratchet, blocking)". A ratchet that cannot take a measurement and then reports OK converts "unknown" into "verified". The five paths: size-limit missing, size-limit without plugins, unexpected size-limit error, no baseline in quality-baseline.json, and a fallback-stat measurement (raw bytes) not comparable to the gzip baseline. **The rule is now one sentence:** under `--ratchet`, "not measured" is exit 1 with the reason and the remedy; advisory mode still exits 0 (a developer running the gate locally without a build should not be blocked). A CI `::error::` annotation is emitted so the reason is visible in the run summary. ## The fix surfaced a second, larger bug Once an unmeasurable run stopped exiting 0, the real gate started failing — and the cause was not the ratchet at all: node_modules\.bin\size-limit:2 basedir=$(dirname "$(echo "$0" | sed -e 's,\,/,g')") SyntaxError: missing ) after argument list `runSizeLimit` invoked `node node_modules/.bin/size-limit --json`. On Windows that path is a **shell shim, not JavaScript**, so `node ` died on every invocation. The preferred measurement mode was unreachable on any Windows machine — always falling through to the raw-byte fallback, which is exactly the path that could never produce a ratchet verdict. It was invisible only because the failure exited 0. Fixed by resolving the package's own bin entry via the repo's existing, already tested `scripts/build/buildToolRunner.mjs` (the helper written for the esbuild Windows postbuild ENOENT incident) rather than inventing a second resolver. It prefers the JS entry, detects native binaries, and falls back to the `.cmd` shim with a shell on win32. The preferred measurement now works on Windows: **9394 bytes** measured against the 10384 baseline, exit 0. Also fixed while threading the root through `runSizeLimit(cwd)`: resolution previously used a module-level `ROOT` and silently ignored its `cwd` argument. ## Verification (release/v3.8.52 @ dbe703a000) | Check | Result | | --- | --- | | `check-bundle-size-unmeasurable.test.ts` (new) | 12/12 pass | | `check-bundle-size.test.ts` (existing) | 21/21 pass | | `build-tool-runner-win-shim.test.ts` | pass (reused helper unaffected) | | `npm run check:bundle-size -- --ratchet` | exit 0, real measurement 9394 | | `npm run check:bundle-size` (advisory) | exit 0 | | `tests/unit/build/*.test.ts` | 534 tests, 12 fail — the same 12 that fail at base | The new suite drives the real CLI as a subprocess against fixture repos with a stubbed size-limit package, and keeps four guard cases so the fix cannot over-correct: advisory still exits 0, a measured regression still blocks, and a clean measurement still passes. ⚠️ base-red inherited: #15246. Refs #15159 (G-04) --- .github/workflows/ci.yml | 5 +- scripts/check/check-bundle-size.mjs | 161 +++++++-- .../check-bundle-size-unmeasurable.test.ts | 323 ++++++++++++++++++ tests/unit/build/check-bundle-size.test.ts | 6 +- 4 files changed, 458 insertions(+), 37 deletions(-) create mode 100644 tests/unit/build/check-bundle-size-unmeasurable.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e4f8cf9c2cd..539e0a81ef37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -341,8 +341,9 @@ jobs: run: npm run check:circular-deps # BLOCKING ratchet (Etapa 2): bundleSize must not regress vs the baseline # (gzip via @size-limit/file, installed by `npm ci`). --ratchet exits 1 on a - # measured regression; it SKIPs (exit 0) when the size-limit plugin/build is - # absent (a non-comparable measurement never blocks). + # measured regression AND on an unmeasurable one (#15159 G-04): "could not + # measure" is not "no regression". A missing @size-limit/file or a missing + # CLI entrypoint now fails loudly here instead of reporting a silent pass. - name: Bundle size (ratchet, blocking) run: npm run check:bundle-size -- --ratchet # CodeQL ratchet foi PROMOVIDO a BLOQUEANTE no job quality-gate (v3.8.26) — diff --git a/scripts/check/check-bundle-size.mjs b/scripts/check/check-bundle-size.mjs index 8569a0837ac9..7b961b2944ea 100644 --- a/scripts/check/check-bundle-size.mjs +++ b/scripts/check/check-bundle-size.mjs @@ -12,31 +12,43 @@ // e soma os bytes. Mesmas entradas, mesma métrica. Emite `bundleSize=`. // // MODO SKIP — entradas inexistentes: -// Se nenhuma das entradas do .size-limit.json existir (ex: build não rodou e os -// arquivos apontados são artefatos gerados), emite `bundleSize=SKIP reason=no-build` -// e sai 0. +// Se nenhuma das entradas do .size-limit.json existir, emite +// `bundleSize=SKIP reason=no-build`. Sob --ratchet isso é exit 1 (G-04): sem +// medição não há veredito. Sem --ratchet, sai 0. // // Por default é ADVISORY: sempre sai 0 independente do resultado. Passe --ratchet // para tornar BLOQUEANTE: lê metrics.bundleSize.value de // config/quality/quality-baseline.json, compara o total MEDIDO e SAI 1 SE — E SOMENTE // SE — o medido for MAIOR que o baseline (regressão real, direction:down). // -// IMPORTANTE: o baseline (5601) é o valor GZIP do size-limit + @size-limit/file +// IMPORTANTE: o baseline (10384) é o valor GZIP do size-limit + @size-limit/file // (instalado por 'npm ci' no CI). O modo FALLBACK-stat lê bytes CRUS (uma métrica // DIFERENTE e maior) — comparar fallback-stat contra o baseline gzip seria um falso- -// positivo. Por isso o --ratchet SÓ bloqueia quando a medição veio do size-limit -// REAL (plugin presente); o fallback-stat e o no-build são SKIP gracioso (exit 0) -// mesmo com --ratchet — falta de plugin/build nunca bloqueia, só uma regressão -// medida na MESMA métrica do baseline bloqueia. +// positivo, então o fallback não pode produzir veredito de ratchet. +// +// #15159 / G-04 — ANTES, todos os caminhos de "não medível" (binário ausente, sem +// plugins, erro inesperado, baseline ausente, métrica não-comparável) saíam 0, +// inclusive sob --ratchet, com comentários explicitando isso. Um ratchet que não +// mede e reporta OK converte "desconhecido" em "verificado" — e o passo do ci.yml +// se chamava "Bundle size (ratchet, blocking)". Agora a regra é uma só: +// +// --ratchet + medição comparável → compara e bloqueia numa regressão real +// --ratchet + NÃO MEDIDO → exit 1, com o motivo e o remédio +// sem --ratchet (advisory) → sempre exit 0, medido ou não // // Uso: // node scripts/check/check-bundle-size.mjs // node scripts/check/check-bundle-size.mjs --json (força saída JSON de size-limit se possível) -// node scripts/check/check-bundle-size.mjs --ratchet (falha exit 1 numa regressão) +// node scripts/check/check-bundle-size.mjs --ratchet (bloqueia numa regressão OU numa falha de medição) import fs from "node:fs"; import path from "node:path"; import { execFileSync } from "node:child_process"; import { pathToFileURL, fileURLToPath } from "node:url"; +import { + resolveLocalBinEntry, + isNativeExecutable, + planBuildToolSpawn, +} from "../build/buildToolRunner.mjs"; const ROOT = process.cwd(); const SIZE_LIMIT_CONFIG = path.join(ROOT, ".size-limit.json"); @@ -44,23 +56,64 @@ const SIZE_LIMIT_BIN = path.join(ROOT, "node_modules", ".bin", "size-limit"); const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json"); const RATCHET = process.argv.includes("--ratchet"); +/** + * Decide COMO invocar o size-limit local. + * + * G-04: antes isto era `execFileSync("node", ["node_modules/.bin/size-limit", + * "--json"])`. Em Windows esse caminho é um shim de shell (e não JavaScript), então + * `node ` morria com "SyntaxError: missing ) after argument list" em TODA + * invocação — o modo de medição preferido era inalcançável numa máquina Windows. + * Isso era invisível enquanto a falha saía 0; ao tornar "não medido" não-zero + * (o resto de G-04), o "aqui nunca dá para medir" ficou visível. + * + * Reusamos o resolvedor já testado de scripts/build/buildToolRunner.mjs (o mesmo + * que corrigiu o ENOENT do esbuild no postbuild Windows) em vez de inventar um + * segundo resolvedor: ele prefere a entry JS do próprio pacote, detecta binário + * nativo e cai para o shim `.cmd` com shell no win32. + * + * @param {readonly string[]} args + * @param {string} [root] repo root to resolve `node_modules` from + * @returns {{file: string, args: string[], shell: boolean}} + */ +export function resolveSizeLimitInvocation(args, root = ROOT) { + const entryPath = resolveLocalBinEntry("size-limit", "size-limit", root); + if (entryPath) { + return planBuildToolSpawn({ + binName: "size-limit", + args, + entryPath, + entryIsNative: isNativeExecutable(entryPath), + root, + }); + } + // Sem a entry do pacote: cai no shim, que em win32 precisa de shell. + return planBuildToolSpawn({ binName: "size-limit", args, entryPath: null, root }); +} + /** * Tenta rodar size-limit --json e retorna o array de resultados. - * Lança se size-limit não tiver plugins instalados (plugins.isEmpty). + * Lança se size-limit não estiver instalado, ou não tiver plugins (plugins.isEmpty). * + * @param {string} [cwd] repo root — usado tanto como cwd do processo quanto para + * resolver `node_modules/size-limit`. * @returns {Array<{name: string, size: number, sizeLimit?: number, passed?: boolean}>} * @throws {SizeLimitNoPluginsError} */ -export function runSizeLimit(cwd = ROOT, binPath = SIZE_LIMIT_BIN) { - if (!fs.existsSync(binPath)) { +export function runSizeLimit(cwd = ROOT) { + const entryPath = resolveLocalBinEntry("size-limit", "size-limit", cwd); + // Sem o pacote instalado, nem o shim existe — sinaliza o fallback-stat. + if (!entryPath && !fs.existsSync(path.join(cwd, "node_modules", ".bin", "size-limit"))) { throw Object.assign(new Error("size-limit binary not found"), { code: "SL_NO_BIN" }); } + + const plan = resolveSizeLimitInvocation(["--json"], cwd); let stdout; try { - stdout = execFileSync("node", [binPath, "--json"], { + stdout = execFileSync(plan.file, plan.args, { encoding: "utf8", cwd, maxBuffer: 8 * 1024 * 1024, + ...(plan.shell ? { shell: true } : {}), }); } catch (err) { const combined = (err.stdout || "") + (err.stderr || ""); @@ -191,8 +244,13 @@ function applyRatchet(totalBytes) { const baselineValue = readBaselineBundleSizeValue(BASELINE_PATH); if (baselineValue === null) { - console.log("[bundle-size] --ratchet: baseline ausente (metrics.bundleSize) — SKIP, sai 0."); - process.exitCode = 0; + // G-04: um ratchet sem baseline não impõe nada — e antes ele saía 0 em + // silêncio, deixando o passo de CI verde sem ter verificado nada. + exitUnmeasurable( + "no-baseline", + "metrics.bundleSize.value não existe em config/quality/quality-baseline.json — " + + "sem baseline congelado não há regressão possível de detectar." + ); return; } @@ -212,6 +270,43 @@ function applyRatchet(totalBytes) { process.exitCode = 0; } +/** + * G-04 (#15159) — um ratchet que NÃO conseguiu medir e mesmo assim sai 0 não é + * um ratchet: converte "desconhecido" em "verificado". Antes desta função, todos + * os caminhos de "não medível" (binário ausente, sem plugins, erro inesperado, + * baseline ausente, métrica não-comparável) imprimiam uma linha informativa e + * retornavam com exit 0 — inclusive sob --ratchet, e o passo correspondente no + * ci.yml se chama "Bundle size (ratchet, blocking)". + * + * Regra agora: em modo ADVISORY continua saindo 0 (um dev local sem build não + * deve ser bloqueado). Sob --ratchet, "não consegui medir" é exit 1 com motivo e + * remédio — porque --ratchet é um pedido explícito de veredito, e o único veredito + * honesto quando a medição é impossível é "não pude verificar isto". + * + * @param {string} reason - token estável para logs/annotations (bundleSize=SKIP reason=…) + * @param {string} hint - o que fazer para tornar a medição possível. + */ +function exitUnmeasurable(reason, hint) { + // Mantido em stdout: é o token que os logs de CI leem. + console.log(`bundleSize=SKIP reason=${reason}`); + + if (!RATCHET) { + console.log(`[bundle-size] ${reason} — modo advisory, seguindo com exit 0.`); + process.exitCode = 0; + return; + } + + console.error( + `[bundle-size] NÃO MEDIDO (${reason}) — o ratchet não pode verificar a métrica.\n` + + ` → ${hint}\n` + + " 'não consegui medir' não é 'sem regressão'. Sob --ratchet isto é exit 1." + ); + if (process.env.CI) { + console.error(`::error title=bundle-size ratchet could not measure::${reason} — ${hint}`); + } + process.exitCode = 1; +} + function main() { // Step 1: tenta com size-limit + plugin instalado let totalBytes = null; @@ -227,14 +322,12 @@ function main() { const { total, entries, allMissing } = measureViaFileStat(SIZE_LIMIT_CONFIG, ROOT); if (allMissing) { - // Step 3: skip gracioso — entradas não existem (build necessário). - // SKIP sai 0 mesmo com --ratchet (build ausente nunca bloqueia). - console.log("bundleSize=SKIP reason=no-build"); - if (process.env.CI) { - console.log( - "::notice::check-bundle-size skipped — entradas do .size-limit.json não encontradas (build necessário)" - ); - } + // Step 3: nenhuma entrada do .size-limit.json existe — não há o que medir. + exitUnmeasurable( + "no-build", + "os arquivos apontados por .size-limit.json não existem; rode o build " + + "(ou restaure os entrypoints de bin/) antes do gate." + ); return; } @@ -248,10 +341,11 @@ function main() { } } } else { - // Erro inesperado — reporta mas não falha (advisory). - // SKIP sai 0 mesmo com --ratchet (erro de medição nunca bloqueia). - console.error(`[bundle-size] Aviso: size-limit retornou erro inesperado: ${err.message}`); - console.log("bundleSize=SKIP reason=size-limit-error"); + exitUnmeasurable( + "size-limit-error", + `size-limit falhou com "${err.message}" — rode \`npm ci\` para reinstalar ` + + "size-limit + @size-limit/file, ou corrija a configuração." + ); return; } } @@ -261,15 +355,16 @@ function main() { // O ratchet só pode comparar a MESMA métrica que congelou o baseline (gzip via // size-limit + @size-limit/file). O fallback-stat lê bytes CRUS — uma métrica - // diferente e maior — então com --ratchet ele faz SKIP gracioso (exit 0) em vez - // de um falso-positivo. Sem --ratchet, ambos os modos só reportam (advisory). + // diferente e maior — então compará-lo ao baseline gzip seria um falso + // positivo. Isso não é um veredito de "sem regressão", é ausência de veredito: + // sob --ratchet é exit 1 (G-04), senão o ratchet fica inerte em silêncio. if (mode !== "size-limit") { if (RATCHET) { - console.log( - `[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — ` + - "--ratchet SKIP (medição não-comparável ao baseline gzip; instale @size-limit/file)." + exitUnmeasurable( + "non-comparable-metric", + `a medição veio de ${mode} (bytes crus), que não é comparável ao baseline ` + + "gzip do size-limit. Instale @size-limit/file para que o ratchet funcione." ); - process.exitCode = 0; return; } console.log(`[bundle-size] ${mode}: total ${kb} KB (${totalBytes} bytes) — advisory, saindo 0`); diff --git a/tests/unit/build/check-bundle-size-unmeasurable.test.ts b/tests/unit/build/check-bundle-size-unmeasurable.test.ts new file mode 100644 index 000000000000..6830ac62acfb --- /dev/null +++ b/tests/unit/build/check-bundle-size-unmeasurable.test.ts @@ -0,0 +1,323 @@ +// tests/unit/build/check-bundle-size-unmeasurable.test.ts +// TDD regression coverage for G-04 (#15159): `check-bundle-size.mjs --ratchet` +// reported "cannot measure" as success. +// +// Four SKIP paths returned exit 0 under `--ratchet`, each with a comment saying +// so explicitly — e.g. check-bundle-size.mjs:252 "SKIP sai 0 mesmo com --ratchet +// (erro de medição nunca bloqueia)". A ratchet that cannot take a measurement and +// then reports OK is not a ratchet: it converts "unknown" into "verified", and it +// does so silently, because every one of those branches printed an informational +// line and exited 0. The step in ci.yml is even labelled "Bundle size (ratchet, +// blocking)". +// +// The rule this suite pins: **under --ratchet, "cannot measure" must be +// non-zero.** Advisory mode stays non-blocking, because a developer running the +// gate locally without a build should not be blocked — but --ratchet is an +// explicit request for a verdict, and the only honest verdict when the +// measurement is impossible is "I could not verify this". +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { execFileSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +const SCRIPT = resolve(process.cwd(), "scripts/check/check-bundle-size.mjs"); + +type RunResult = { status: number; stdout: string; stderr: string }; + +function runGate(root: string, args: string[]): RunResult { + try { + const stdout = execFileSync(process.execPath, [SCRIPT, ...args], { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, CI: "" }, + }); + return { status: 0, stdout, stderr: "" }; + } catch (err) { + const e = err as { status?: number; stdout?: string; stderr?: string }; + return { status: e.status ?? 1, stdout: e.stdout ?? "", stderr: e.stderr ?? "" }; + } +} + +/** + * Install a fake `size-limit` package into `root/node_modules`. + * + * The gate resolves the package's own JS entry (node_modules/size-limit/bin.js), + * NOT the node_modules/.bin shim — that indirection is the Windows bug G-04 fixes. + * So a fixture must stub the package entry for the stub to take effect. + * + * @param {string} root + * @param {string} body JS source for the fake bin.js + */ +function installFakeSizeLimit(root: string, body: string): void { + const pkgDir = join(root, "node_modules", "size-limit"); + mkdirSync(pkgDir, { recursive: true }); + writeFileSync( + join(pkgDir, "package.json"), + JSON.stringify({ name: "size-limit", version: "0.0.0", bin: { "size-limit": "bin.js" } }) + ); + writeFileSync(join(pkgDir, "bin.js"), body); +} + +/** Write the .size-limit.json + a matching entry file, as the real repo has. */ +function writeSizeLimitConfig(root: string, entryPath: string): void { + writeFileSync( + join(root, ".size-limit.json"), + JSON.stringify([{ name: "CLI", path: entryPath, limit: "15 KB" }]) + ); + mkdirSync(join(root, "config", "quality"), { recursive: true }); + writeFileSync( + join(root, "config", "quality", "quality-baseline.json"), + JSON.stringify({ metrics: { bundleSize: { value: 10384 } } }) + ); +} + +/** + * Build a fixture repo whose size-limit behaves as given, so the gate's + * measurement-failure branches can be reached deterministically. + */ +function withStubbedSizeLimit(behaviour: "no-plugins" | "crash", fn: (root: string) => void): void { + const root = mkdtempSync(join(tmpdir(), "bundle-unmeasurable-")); + try { + const body = + behaviour === "no-plugins" + ? `console.error("Install Size Limit preset to measure bundles");process.exit(1);\n` + : `console.error("size-limit: internal explosion");process.exit(7);\n`; + installFakeSizeLimit(root, body); + + // .size-limit.json points at committed sources, as the real repo does. + writeSizeLimitConfig(root, "bin/omniroute.mjs"); + mkdirSync(join(root, "bin"), { recursive: true }); + writeFileSync(join(root, "bin", "omniroute.mjs"), "export const x = 1;\n"); + + fn(root); + } finally { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +} + +function withMissingBuild(fn: (root: string) => void): void { + const root = mkdtempSync(join(tmpdir(), "bundle-nobuild-")); + try { + // No node_modules/.bin/size-limit at all → SL_NO_BIN → fallback-stat. + // And .size-limit.json points at artifacts that do not exist → allMissing. + writeFileSync( + join(root, ".size-limit.json"), + JSON.stringify([{ name: "Gone", path: "dist/never-built.mjs", limit: "1 KB" }]) + ); + fn(root); + } finally { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +} + +// --------------------------------------------------------------------------- +// G-04 — the four "cannot measure" paths must not report success under --ratchet +// --------------------------------------------------------------------------- + +test("G-04: an unexpected size-limit error is non-zero under --ratchet", () => { + withStubbedSizeLimit("crash", (root) => { + const result = runGate(root, ["--ratchet"]); + + assert.equal( + result.status, + 1, + `a measurement failure must not exit 0 under --ratchet; got status=${result.status}\nout=${result.stdout}\nerr=${result.stderr}` + ); + const output = result.stdout + result.stderr; + assert.match(output, /bundleSize=SKIP reason=size-limit-error/, "must still name the reason"); + }); +}); + +test("G-04: a size-limit with no plugins is non-zero under --ratchet", () => { + withStubbedSizeLimit("no-plugins", (root) => { + const result = runGate(root, ["--ratchet"]); + + assert.equal( + result.status, + 1, + `without @size-limit/file the baseline cannot be verified; got status=${result.status}\nout=${result.stdout}` + ); + }); +}); + +test("G-04: no measurable build artifact is non-zero under --ratchet", () => { + withMissingBuild((root) => { + const result = runGate(root, ["--ratchet"]); + + assert.equal( + result.status, + 1, + `"no-build" is a failed measurement, not a pass; got status=${result.status}\nout=${result.stdout}` + ); + const output = result.stdout + result.stderr; + assert.match(output, /bundleSize=SKIP reason=no-build/); + }); +}); + +test("G-04: every unmeasurable path explains how to produce a measurement", () => { + withStubbedSizeLimit("crash", (root) => { + const result = runGate(root, ["--ratchet"]); + const output = result.stdout + result.stderr; + + // A gate that fails without saying what to do gets disabled next week. + assert.match( + output, + /size-limit|npm (ci|install)|@size-limit/i, + `the failure must name the tool that failed or the fix; got: ${output.trim()}` + ); + }); +}); + +test("G-04: a missing baseline is non-zero under --ratchet", () => { + const root = mkdtempSync(join(tmpdir(), "bundle-nobaseline-")); + try { + // A working stub that reports a measurement, but there is no baseline file, + // so the ratchet has nothing to compare against and enforces nothing. + installFakeSizeLimit(root, `console.log(JSON.stringify([{name:"CLI",size:1234}]));\n`); + writeFileSync( + join(root, ".size-limit.json"), + JSON.stringify([{ name: "CLI", path: "bin/omniroute.mjs", limit: "15 KB" }]) + ); + + const result = runGate(root, ["--ratchet"]); + + assert.equal( + result.status, + 1, + `a ratchet with no baseline enforces nothing and must say so; got status=${result.status}\nout=${result.stdout}` + ); + assert.match(result.stdout + result.stderr, /baseline/i); + } finally { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); + +// --------------------------------------------------------------------------- +// Advisory mode must stay non-blocking — this is the distinction that matters +// --------------------------------------------------------------------------- + +test("G-04: advisory mode still exits 0 when it cannot measure", () => { + withStubbedSizeLimit("crash", (root) => { + const result = runGate(root, []); + + assert.equal( + result.status, + 0, + `advisory mode must not block a local run; got status=${result.status}\nout=${result.stdout}` + ); + assert.match(result.stdout + result.stderr, /bundleSize=SKIP reason=size-limit-error/); + }); +}); + +test("G-04: advisory mode still exits 0 with no build", () => { + withMissingBuild((root) => { + const result = runGate(root, []); + + assert.equal(result.status, 0, `got status=${result.status}\nout=${result.stdout}`); + }); +}); + +// --------------------------------------------------------------------------- +// A real regression must still be caught (guard against over-correcting) +// --------------------------------------------------------------------------- + +test("G-04: a measured regression above the baseline is still non-zero", () => { + const root = mkdtempSync(join(tmpdir(), "bundle-regression-")); + try { + // 20000 bytes measured against a 10384 baseline. + installFakeSizeLimit(root, `console.log(JSON.stringify([{name:"CLI",size:20000}]));\n`); + writeSizeLimitConfig(root, "bin/omniroute.mjs"); + + const result = runGate(root, ["--ratchet"]); + + assert.equal(result.status, 1, `a real regression must block; got status=${result.status}`); + assert.match(result.stdout + result.stderr, /REGRESS/i); + } finally { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); + +test("G-04: a measurement at or below the baseline still passes under --ratchet", () => { + const root = mkdtempSync(join(tmpdir(), "bundle-clean-")); + try { + installFakeSizeLimit(root, `console.log(JSON.stringify([{name:"CLI",size:5000}]));\n`); + writeSizeLimitConfig(root, "bin/omniroute.mjs"); + + const result = runGate(root, ["--ratchet"]); + + assert.equal(result.status, 0, `a clean measurement must pass; got ${result.stdout}`); + } finally { + rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +}); + +test("the gate script exists at the path package.json invokes", () => { + assert.ok(existsSync(SCRIPT), `expected the gate at ${SCRIPT}`); +}); + +// --------------------------------------------------------------------------- +// G-04 follow-on: the measurement itself never worked on Windows +// --------------------------------------------------------------------------- +// +// runSizeLimit used to exec `node node_modules/.bin/size-limit --json`. On +// Windows that path is a shell shim, not JavaScript, so `node ` died with +// "SyntaxError: missing ) after argument list" on every invocation — the +// preferred measurement mode was unreachable on a Windows dev machine. That was +// invisible while the failure exited 0; once G-04 made an unmeasurable run +// non-zero, the underlying "can never measure here" became the visible failure. +// +// The repo already solved this exact class for esbuild in +// scripts/build/buildToolRunner.mjs (Windows postbuild ENOENT incident), so the +// fix reuses that resolution rather than inventing a second one. + +test("G-04: runSizeLimit resolves the package's own JS entry, not the .bin shim", async () => { + const mod = (await import(pathToFileURL(SCRIPT).href)) as { + resolveSizeLimitInvocation?: (args: string[]) => { + file: string; + args: string[]; + shell: boolean; + }; + }; + assert.equal( + typeof mod.resolveSizeLimitInvocation, + "function", + "check-bundle-size.mjs must expose how it invokes size-limit so the Windows path is testable" + ); + + const plan = mod.resolveSizeLimitInvocation!(["--json"]); + + assert.equal(plan.file, process.execPath, "a JS entry must run on this Node binary"); + assert.equal(plan.shell, false, "no shell means no argument-escaping hazard"); + + // Separator-agnostic: this suite runs on Windows (backslashes) and Linux. + const entry = (plan.args[0] ?? "").replace(/\\/g, "/"); + assert.match( + entry, + /size-limit\/bin(\.js)?$/, + `must resolve the real size-limit JS entry, got ${plan.args[0]}` + ); + assert.ok( + !entry.includes("/.bin/"), + `must never hand a node_modules/.bin shim to node — that is the Windows crash; got ${entry}` + ); +}); + +test("G-04: size-limit actually runs through the resolved entry on this platform", async () => { + const mod = (await import(pathToFileURL(SCRIPT).href)) as { + runSizeLimit?: (cwd?: string) => Array<{ name: string; size: number }>; + }; + assert.equal(typeof mod.runSizeLimit, "function"); + + // The real gate, on the real repo, on whatever platform this is. Before the + // fix this threw on Windows; it is the regression guard for the whole path. + const results = mod.runSizeLimit!(process.cwd()); + assert.ok(Array.isArray(results) && results.length > 0, "size-limit must return entries"); + assert.ok( + results.every((r) => typeof r.size === "number"), + "every entry must carry a numeric size, otherwise the ratchet cannot compare" + ); +}); diff --git a/tests/unit/build/check-bundle-size.test.ts b/tests/unit/build/check-bundle-size.test.ts index a6924ef6a0b1..09833a083be7 100644 --- a/tests/unit/build/check-bundle-size.test.ts +++ b/tests/unit/build/check-bundle-size.test.ts @@ -145,9 +145,11 @@ test("measureViaFileStat: config ausente retorna allMissing=true e total=0", () // runSizeLimit — comportamento quando o binário não existe // --------------------------------------------------------------------------- -test("runSizeLimit: lança com code SL_NO_BIN quando binário não existe", () => { +test("runSizeLimit: lança com code SL_NO_BIN quando size-limit não está instalado", () => { + // The gate resolves node_modules//package.json → its bin entry (the .bin + // shim is unusable on Windows — see G-04). A root with neither is "not installed". assert.throws( - () => runSizeLimit("/tmp", "/nonexistent/path/size-limit"), + () => runSizeLimit("/nonexistent/omniroute-root-4242"), (err: unknown) => { assert.ok(err instanceof Error); assert.equal((err as NodeJS.ErrnoException & { code?: string }).code, "SL_NO_BIN");