From b1625252d5acfc8f04fa665a58cf1fa55b3be3a4 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 21:26:51 +0900 Subject: [PATCH] feat: enforce web vitals performance budgets --- config/performance/budgets.json | 18 +++ config/performance/field-input.example.json | 5 + config/schemas/field-web-vitals.schema.json | 34 ++++ config/schemas/lab-performance.schema.json | 30 ++++ docs/operations/performance-evidence.md | 17 ++ package.json | 5 +- scripts/check-bundle.mjs | 93 +++++++++++ scripts/collect-web-vitals-evidence.mjs | 102 ++++++++++++ scripts/test-performance.mjs | 148 ++++++++++++++++++ .../policies/performance-budgets.js | 96 ++++++++++++ tests/unit/performance-budgets.test.js | 77 +++++++++ 11 files changed, 624 insertions(+), 1 deletion(-) create mode 100644 config/performance/budgets.json create mode 100644 config/performance/field-input.example.json create mode 100644 config/schemas/field-web-vitals.schema.json create mode 100644 config/schemas/lab-performance.schema.json create mode 100644 docs/operations/performance-evidence.md create mode 100644 scripts/check-bundle.mjs create mode 100644 scripts/collect-web-vitals-evidence.mjs create mode 100644 scripts/test-performance.mjs create mode 100644 src/application/policies/performance-budgets.js create mode 100644 tests/unit/performance-budgets.test.js diff --git a/config/performance/budgets.json b/config/performance/budgets.json new file mode 100644 index 0000000..adf9cc7 --- /dev/null +++ b/config/performance/budgets.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "bundle": { + "initialJsGzipBytes": 204800, + "lazyChunkGzipBytes": 122880 + }, + "lab": { + "lcpMs": 2500, + "cls": 0.1, + "namedInteractionMs": 200 + }, + "field": { + "p75LcpMs": 2500, + "p75Cls": 0.1, + "p75InpMs": 200, + "minimumEligibleSamples": null + } +} diff --git a/config/performance/field-input.example.json b/config/performance/field-input.example.json new file mode 100644 index 0000000..9223dae --- /dev/null +++ b/config/performance/field-input.example.json @@ -0,0 +1,5 @@ +{ + "schemaVersion": 1, + "releaseId": "local-release", + "samples": [] +} diff --git a/config/schemas/field-web-vitals.schema.json b/config/schemas/field-web-vitals.schema.json new file mode 100644 index 0000000..d63048c --- /dev/null +++ b/config/schemas/field-web-vitals.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "ART-FE-FIELD-WEB-VITALS@1", + "type": "object", + "required": [ + "schemaVersion", + "generatedAt", + "window", + "context", + "metrics", + "eligibility", + "status", + "passed" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "generatedAt": { "type": "string", "format": "date-time" }, + "window": { "type": "object", "required": ["days", "start", "end"] }, + "context": { + "type": "object", + "required": ["source", "network", "routeAggregation", "releaseId"] + }, + "metrics": { "type": "object" }, + "eligibility": { + "type": "object", + "required": ["consentRequired", "eligibleSamples", "minimumEligibleSamples"] + }, + "status": { + "enum": ["PASS", "FAIL_THRESHOLD", "FAIL_UNVERIFIED"] + }, + "passed": { "type": "boolean" } + }, + "additionalProperties": false +} diff --git a/config/schemas/lab-performance.schema.json b/config/schemas/lab-performance.schema.json new file mode 100644 index 0000000..878b9f3 --- /dev/null +++ b/config/schemas/lab-performance.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "ART-FE-LAB@1", + "type": "object", + "required": [ + "schemaVersion", + "generatedAt", + "context", + "metrics", + "thresholds", + "fixtures", + "passed" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "generatedAt": { "type": "string", "format": "date-time" }, + "context": { + "type": "object", + "required": ["runner", "browser", "viewport", "network", "cpu", "cache", "build"] + }, + "metrics": { + "type": "object", + "required": ["lcpMs", "cls", "namedInteractionMs"] + }, + "thresholds": { "type": "object" }, + "fixtures": { "type": "array", "minItems": 2 }, + "passed": { "type": "boolean" } + }, + "additionalProperties": false +} diff --git a/docs/operations/performance-evidence.md b/docs/operations/performance-evidence.md new file mode 100644 index 0000000..29864ce --- /dev/null +++ b/docs/operations/performance-evidence.md @@ -0,0 +1,17 @@ +# Performance evidence contract + +Performance evidence is deliberately split by measurement context: + +- `bundle.json` records production build output and enforces initial JavaScript + at 200 KiB gzip and every lazy chunk at 120 KiB gzip. +- `lab.json` records Chromium/runner/viewport/network/CPU/cache/build context and + enforces LCP 2.5 s, CLS 0.10, and the named route interaction at 200 ms. +- `field-web-vitals.json` records consent-filtered, route-ID aggregated, + release-specific production samples over 28 days and evaluates p75 LCP, CLS, + and INP against 2.5 s, 0.10, and 200 ms. + +The field minimum eligible-sample threshold is intentionally unresolved until +a privacy-approved telemetry baseline exists. Therefore the field command +fails closed with `FAIL_UNVERIFIED` when run against the example input. Provide +`FIELD_WEB_VITALS_INPUT` and a reviewed `MIN_ELIGIBLE_SAMPLES` only after that +decision is recorded. diff --git a/package.json b/package.json index f5d6b94..52d7ad5 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,10 @@ "check:registries": "node scripts/check-registries.mjs", "verify:compatibility": "node scripts/check-compatibility.mjs", "verify:release": "node scripts/verify-release.mjs", - "verify:hosting-headers": "node scripts/verify-hosting-headers.mjs" + "verify:hosting-headers": "node scripts/verify-hosting-headers.mjs", + "check:bundle": "node scripts/generate-supply-chain.mjs && node scripts/check-bundle.mjs", + "test:performance": "node scripts/test-performance.mjs", + "collect:web-vitals-evidence": "node scripts/collect-web-vitals-evidence.mjs" }, "dependencies": { "@tanstack/react-query": "5.101.4", diff --git a/scripts/check-bundle.mjs b/scripts/check-bundle.mjs new file mode 100644 index 0000000..e2fdf84 --- /dev/null +++ b/scripts/check-bundle.mjs @@ -0,0 +1,93 @@ +import { readFile, writeFile } from "node:fs/promises"; + +import { evaluateBundleBudget } from "../src/application/policies/performance-budgets.js"; + +const report = + /** @type {{ + * outputs: Array<{ path: string, gzipBytes: number }>, + * [key: string]: unknown + * }} */ ( + JSON.parse(await readFile("artifacts/performance/bundle.json", "utf8")) + ); +const viteManifest = + /** @type {Record} */ ( + JSON.parse(await readFile("dist/.vite/manifest.json", "utf8")) + ); +const budgets = + /** @type {{ initialJsGzipBytes: number, lazyChunkGzipBytes: number }} */ ( + JSON.parse(await readFile("config/performance/budgets.json", "utf8")).bundle + ); + +const outputByPath = new Map( + report.outputs.map((output) => [output.path.replace(/^dist\//, ""), output]), +); +const initialFiles = new Set( + Object.values(viteManifest) + .filter((entry) => entry.isEntry) + .map((entry) => entry.file), +); +const lazyFiles = new Set( + Object.values(viteManifest) + .filter((entry) => !entry.isEntry && entry.file.endsWith(".js")) + .map((entry) => entry.file), +); +const initialJsGzipBytes = [...initialFiles].reduce( + (total, file) => total + (outputByPath.get(file)?.gzipBytes ?? 0), + 0, +); +const lazyChunks = [...lazyFiles].map((file) => ({ + path: file, + gzipBytes: outputByPath.get(file)?.gzipBytes ?? 0, +})); +const measurements = { initialJsGzipBytes, lazyChunks }; +const result = evaluateBundleBudget(measurements, budgets); +const fixtures = [ + { + name: "initial-js-over-budget", + passed: + !evaluateBundleBudget( + { + initialJsGzipBytes: budgets.initialJsGzipBytes + 1, + lazyChunks: [], + }, + budgets, + ).passed, + }, + { + name: "lazy-chunk-over-budget", + passed: + !evaluateBundleBudget( + { + initialJsGzipBytes: 0, + lazyChunks: [ + { + path: "fixture.js", + gzipBytes: budgets.lazyChunkGzipBytes + 1, + }, + ], + }, + budgets, + ).passed, + }, +]; +const passed = result.passed && fixtures.every((fixture) => fixture.passed); +const completedReport = { + ...report, + measurements, + thresholds: budgets, + results: result, + fixtures, + passed, +}; + +await writeFile( + "artifacts/performance/bundle.json", + `${JSON.stringify(completedReport, null, 2)}\n`, +); +if (!passed) { + process.stderr.write("Bundle budget exceeded.\n"); + process.exit(1); +} +process.stdout.write( + `Bundle budget: PASS (initial JS ${initialJsGzipBytes} / ${budgets.initialJsGzipBytes} gzip bytes)\n`, +); diff --git a/scripts/collect-web-vitals-evidence.mjs b/scripts/collect-web-vitals-evidence.mjs new file mode 100644 index 0000000..4eac58c --- /dev/null +++ b/scripts/collect-web-vitals-evidence.mjs @@ -0,0 +1,102 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; + +import { + evaluateFieldBudget, + percentile75, +} from "../src/application/policies/performance-budgets.js"; + +const inputPath = + process.env.FIELD_WEB_VITALS_INPUT ?? + "config/performance/field-input.example.json"; +const input = + /** @type {{ + * releaseId: string, + * samples: Array<{ + * timestamp: string, + * consent: boolean, + * releaseId: string, + * routeId: string, + * lcpMs: number, + * cls: number, + * inpMs: number + * }> + * }} */ (JSON.parse(await readFile(inputPath, "utf8"))); +const configured = + /** @type {{ + * p75LcpMs: number, + * p75Cls: number, + * p75InpMs: number, + * minimumEligibleSamples: number | null + * }} */ ( + JSON.parse(await readFile("config/performance/budgets.json", "utf8")).field + ); +const minimumEligibleSamples = process.env.MIN_ELIGIBLE_SAMPLES + ? Number(process.env.MIN_ELIGIBLE_SAMPLES) + : configured.minimumEligibleSamples; +const end = new Date(); +const start = new Date(end); +start.setUTCDate(start.getUTCDate() - 28); +const eligible = input.samples.filter((sample) => { + const timestamp = new Date(sample.timestamp); + return ( + sample.consent === true && + sample.releaseId === input.releaseId && + timestamp >= start && + timestamp <= end + ); +}); +const metrics = { + p75LcpMs: percentile75(eligible.map((sample) => sample.lcpMs)), + p75Cls: percentile75(eligible.map((sample) => sample.cls)), + p75InpMs: percentile75(eligible.map((sample) => sample.inpMs)), +}; +const thresholds = { ...configured, minimumEligibleSamples }; +const result = evaluateFieldBudget( + { metrics, eligibleSamples: eligible.length }, + thresholds, +); +const routeSamples = Object.fromEntries( + Object.entries( + eligible.reduce( + (counts, sample) => { + counts[sample.routeId] = (counts[sample.routeId] ?? 0) + 1; + return counts; + }, + /** @type {Record} */ ({}), + ), + ).sort(([left], [right]) => left.localeCompare(right)), +); +const report = { + schemaVersion: 1, + generatedAt: end.toISOString(), + window: { days: 28, start: start.toISOString(), end: end.toISOString() }, + context: { + source: inputPath, + network: "production-real-user", + routeAggregation: "route-id-only", + releaseId: input.releaseId, + }, + metrics, + thresholds, + eligibility: { + consentRequired: true, + eligibleSamples: eligible.length, + minimumEligibleSamples, + routeSamples, + }, + status: result.status, + passed: result.passed, +}; + +await mkdir("artifacts/performance", { recursive: true }); +await writeFile( + "artifacts/performance/field-web-vitals.json", + `${JSON.stringify(report, null, 2)}\n`, +); +if (!result.passed) { + process.stderr.write( + `Field Web Vitals: ${result.status} (minimum eligible sample threshold and 28-day production data are required)\n`, + ); + process.exit(1); +} +process.stdout.write("Field Web Vitals: PASS\n"); diff --git a/scripts/test-performance.mjs b/scripts/test-performance.mjs new file mode 100644 index 0000000..6f5eb4a --- /dev/null +++ b/scripts/test-performance.mjs @@ -0,0 +1,148 @@ +import { spawn } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { performance } from "node:perf_hooks"; +import process from "node:process"; + +import { chromium } from "@playwright/test"; + +import { evaluateLabBudget } from "../src/application/policies/performance-budgets.js"; + +const server = spawn( + "corepack", + ["pnpm", "preview", "--host", "127.0.0.1", "--port", "4173"], + { stdio: "ignore" }, +); +const baseUrl = "http://127.0.0.1:4173"; + +async function waitForServer() { + for (let attempt = 0; attempt < 50; attempt += 1) { + try { + const response = await fetch(baseUrl); + if (response.ok) return; + } catch { + // The bounded retry loop handles startup races. + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("Preview server did not become ready."); +} + +try { + await waitForServer(); + const release = JSON.parse( + await readFile("dist/release-manifest.json", "utf8"), + ); + const thresholds = JSON.parse( + await readFile("config/performance/budgets.json", "utf8"), + ).lab; + const browser = await chromium.launch(); + try { + const context = await browser.newContext({ + viewport: { width: 1280, height: 720 }, + }); + const page = await context.newPage(); + const cdp = await context.newCDPSession(page); + await cdp.send("Network.enable"); + await cdp.send("Network.emulateNetworkConditions", { + offline: false, + latency: 40, + downloadThroughput: 200_000, + uploadThroughput: 93_750, + connectionType: "cellular4g", + }); + await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 }); + await page.addInitScript(() => { + const evidence = { lcpMs: 0, cls: 0 }; + /** @type {any} */ (window).__contractPerformance = evidence; + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) evidence.lcpMs = entry.startTime; + }).observe({ type: "largest-contentful-paint", buffered: true }); + new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (!(/** @type {any} */ (entry)).hadRecentInput) { + evidence.cls += /** @type {any} */ (entry).value; + } + } + }).observe({ type: "layout-shift", buffered: true }); + }); + await page.goto(baseUrl, { waitUntil: "networkidle" }); + const interactionStarted = performance.now(); + await page.getByRole("link", { name: "샘플 리소스" }).click(); + await page.getByRole("heading", { name: "세션이 필요합니다." }).waitFor(); + const namedInteractionMs = performance.now() - interactionStarted; + const paint = await page.evaluate( + () => /** @type {any} */ (window).__contractPerformance, + ); + const contextMetadata = { + runner: { + platform: process.platform, + architecture: process.arch, + nodeVersion: process.version, + }, + browser: { name: "chromium", version: await browser.version() }, + viewport: { width: 1280, height: 720 }, + network: { + profile: "contract-fast-4g", + latencyMs: 40, + downloadBytesPerSecond: 200_000, + uploadBytesPerSecond: 93_750, + }, + cpu: { throttlingRate: 4 }, + cache: { state: "cold", isolation: "new-browser-context" }, + build: { buildId: release.buildId, releaseId: release.releaseId }, + }; + const metrics = { + lcpMs: Math.round(paint.lcpMs), + cls: Number(paint.cls.toFixed(4)), + namedInteractionMs: Math.round(namedInteractionMs), + }; + const result = evaluateLabBudget( + { context: contextMetadata, metrics }, + thresholds, + ); + const fixtures = [ + { + name: "missing-context", + passed: !evaluateLabBudget({ metrics }, thresholds).passed, + }, + { + name: "lcp-over-threshold", + passed: !evaluateLabBudget( + { + context: contextMetadata, + metrics: { ...metrics, lcpMs: thresholds.lcpMs + 1 }, + }, + thresholds, + ).passed, + }, + ]; + const passed = result.passed && fixtures.every((fixture) => fixture.passed); + await mkdir("artifacts/performance", { recursive: true }); + await writeFile( + "artifacts/performance/lab.json", + `${JSON.stringify( + { + schemaVersion: 1, + generatedAt: new Date().toISOString(), + context: contextMetadata, + metrics, + thresholds, + fixtures, + passed, + }, + null, + 2, + )}\n`, + ); + if (!passed) { + throw new Error(`Lab performance failed: ${JSON.stringify(metrics)}`); + } + process.stdout.write( + `Lab performance: PASS (LCP ${metrics.lcpMs}ms, CLS ${metrics.cls}, interaction ${metrics.namedInteractionMs}ms)\n`, + ); + } finally { + await browser.close(); + } +} finally { + server.kill("SIGTERM"); +} diff --git a/src/application/policies/performance-budgets.js b/src/application/policies/performance-budgets.js new file mode 100644 index 0000000..3c2b411 --- /dev/null +++ b/src/application/policies/performance-budgets.js @@ -0,0 +1,96 @@ +/** + * @param {{ + * initialJsGzipBytes: number, + * lazyChunks: Array<{ path: string, gzipBytes: number }> + * }} measurements + * @param {{ initialJsGzipBytes: number, lazyChunkGzipBytes: number }} thresholds + */ +export function evaluateBundleBudget(measurements, thresholds) { + const initialPassed = + measurements.initialJsGzipBytes <= thresholds.initialJsGzipBytes; + const lazyResults = measurements.lazyChunks.map((chunk) => ({ + ...chunk, + threshold: thresholds.lazyChunkGzipBytes, + passed: chunk.gzipBytes <= thresholds.lazyChunkGzipBytes, + })); + return Object.freeze({ + initialPassed, + lazyResults: Object.freeze(lazyResults), + passed: initialPassed && lazyResults.every((chunk) => chunk.passed), + }); +} + +/** + * @param {{ + * context?: Record, + * metrics: { lcpMs: number, cls: number, namedInteractionMs: number } + * }} report + * @param {{ lcpMs: number, cls: number, namedInteractionMs: number }} thresholds + */ +export function evaluateLabBudget(report, thresholds) { + const requiredContext = [ + "runner", + "browser", + "viewport", + "network", + "cpu", + "cache", + "build", + ]; + const missingContext = requiredContext.filter( + (field) => report.context?.[field] === undefined, + ); + const results = { + lcp: report.metrics.lcpMs <= thresholds.lcpMs, + cls: report.metrics.cls <= thresholds.cls, + namedInteraction: + report.metrics.namedInteractionMs <= thresholds.namedInteractionMs, + }; + return Object.freeze({ + missingContext: Object.freeze(missingContext), + results: Object.freeze(results), + passed: missingContext.length === 0 && Object.values(results).every(Boolean), + }); +} + +/** @param {number[]} values */ +export function percentile75(values) { + if (values.length === 0) return null; + const sorted = [...values].sort((left, right) => left - right); + return sorted[Math.ceil(sorted.length * 0.75) - 1]; +} + +/** + * @param {{ + * metrics: { p75LcpMs: number | null, p75Cls: number | null, p75InpMs: number | null }, + * eligibleSamples: number + * }} report + * @param {{ + * p75LcpMs: number, + * p75Cls: number, + * p75InpMs: number, + * minimumEligibleSamples: number | null + * }} thresholds + */ +export function evaluateFieldBudget(report, thresholds) { + if ( + thresholds.minimumEligibleSamples === null || + report.eligibleSamples < thresholds.minimumEligibleSamples || + Object.values(report.metrics).some((value) => value === null) + ) { + return Object.freeze({ + status: /** @type {const} */ ("FAIL_UNVERIFIED"), + passed: false, + }); + } + const passed = + /** @type {number} */ (report.metrics.p75LcpMs) <= thresholds.p75LcpMs && + /** @type {number} */ (report.metrics.p75Cls) <= thresholds.p75Cls && + /** @type {number} */ (report.metrics.p75InpMs) <= thresholds.p75InpMs; + return Object.freeze({ + status: passed + ? /** @type {const} */ ("PASS") + : /** @type {const} */ ("FAIL_THRESHOLD"), + passed, + }); +} diff --git a/tests/unit/performance-budgets.test.js b/tests/unit/performance-budgets.test.js new file mode 100644 index 0000000..774382d --- /dev/null +++ b/tests/unit/performance-budgets.test.js @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { + evaluateBundleBudget, + evaluateFieldBudget, + evaluateLabBudget, + percentile75, +} from "../../src/application/policies/performance-budgets.js"; + +describe("performance budgets", () => { + it("rejects initial and lazy JavaScript above their named limits", () => { + const thresholds = { + initialJsGzipBytes: 200, + lazyChunkGzipBytes: 120, + }; + expect( + evaluateBundleBudget( + { initialJsGzipBytes: 201, lazyChunks: [] }, + thresholds, + ).passed, + ).toBe(false); + expect( + evaluateBundleBudget( + { + initialJsGzipBytes: 100, + lazyChunks: [{ path: "lazy.js", gzipBytes: 121 }], + }, + thresholds, + ).passed, + ).toBe(false); + }); + + it("fails lab evidence when context is absent or a metric is over budget", () => { + const thresholds = { lcpMs: 2500, cls: 0.1, namedInteractionMs: 200 }; + expect( + evaluateLabBudget( + { metrics: { lcpMs: 1000, cls: 0, namedInteractionMs: 50 } }, + thresholds, + ), + ).toMatchObject({ passed: false }); + expect( + evaluateLabBudget( + { + context: { + runner: {}, + browser: {}, + viewport: {}, + network: {}, + cpu: {}, + cache: {}, + build: {}, + }, + metrics: { lcpMs: 2501, cls: 0, namedInteractionMs: 50 }, + }, + thresholds, + ).passed, + ).toBe(false); + }); + + it("uses the nearest-rank p75 and fails closed while sample minimum is deferred", () => { + expect(percentile75([4, 1, 3, 2])).toBe(3); + expect( + evaluateFieldBudget( + { + metrics: { p75LcpMs: 1000, p75Cls: 0.01, p75InpMs: 50 }, + eligibleSamples: 100, + }, + { + p75LcpMs: 2500, + p75Cls: 0.1, + p75InpMs: 200, + minimumEligibleSamples: null, + }, + ), + ).toEqual({ status: "FAIL_UNVERIFIED", passed: false }); + }); +});