From 86ffbbcdbccf3308eeecbb84eb34efd8f50ef47e Mon Sep 17 00:00:00 2001 From: chenhang Date: Wed, 15 Jul 2026 17:34:17 +0800 Subject: [PATCH] ci: establish trusted quality baseline --- .gitea/workflows/docker-image.yml | 23 ++++++++++++ docs/performance-baseline.md | 32 +++++++++-------- package.json | 3 ++ scripts/performance/report-bundle.mjs | 45 +++++++++++++++++++++++ src/data/dto/user/__tests__/user.test.ts | 2 ++ src/data/dto/user/user.ts | 2 ++ vitest.config.ts | 46 ++++++++++++++++++++++-- 7 files changed, 136 insertions(+), 17 deletions(-) diff --git a/.gitea/workflows/docker-image.yml b/.gitea/workflows/docker-image.yml index b1639f90..fef715cc 100644 --- a/.gitea/workflows/docker-image.yml +++ b/.gitea/workflows/docker-image.yml @@ -8,8 +8,31 @@ on: workflow_dispatch: jobs: + quality: + name: Quality and Bundle Budgets + runs-on: gitea-label + container: + image: cozsweet-act-runner-node24-docker:latest + env: + NEXT_TELEMETRY_DISABLED: "1" + SENTRY_UPLOAD_SOURCEMAPS: "0" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run quality checks + run: pnpm quality + + - name: Enforce bundle budgets + run: pnpm perf:bundle:check + publish: name: Build and Push Docker Image + needs: quality runs-on: gitea-label container: image: cozsweet-act-runner-node24-docker:latest diff --git a/docs/performance-baseline.md b/docs/performance-baseline.md index 9f79f6bc..aeb76a3c 100644 --- a/docs/performance-baseline.md +++ b/docs/performance-baseline.md @@ -1,6 +1,6 @@ # Performance baseline -Baseline date: 2026-07-14 +Baseline date: 2026-07-15 Next.js: 16.2.7 (Turbopack) @@ -15,8 +15,12 @@ Run: ```bash pnpm perf:bundle +pnpm perf:bundle:check ``` +`perf:bundle` generates the report without failing. `perf:bundle:check` also +enforces the eager client JavaScript budgets used by CI. + The command uses the built-in Next.js Turbopack analyzer and writes: - Interactive analysis: `.next/diagnostics/analyze/` @@ -24,19 +28,19 @@ The command uses the built-in Next.js Turbopack analyzer and writes: Compressed sizes below are Turbopack module estimates. `Eager` is the route's synchronous client graph; `async` is code behind at least one dynamic import. -| Route | Before direct imports | Eager baseline | Change | Async baseline | +| Route | Direct-import baseline | Current eager baseline | Change | Current async baseline | | --- | ---: | ---: | ---: | ---: | -| `/splash` | 647.2 KiB | 646.2 KiB | -1.0 KiB | 14.3 KiB | -| `/chat` | 670.5 KiB | 669.8 KiB | -0.7 KiB | 14.7 KiB | -| `/subscription` | 660.1 KiB | 658.4 KiB | -1.7 KiB | 20.6 KiB | -| `/tip` | 653.3 KiB | 651.6 KiB | -1.7 KiB | 20.6 KiB | +| `/splash` | 646.2 KiB | 649.5 KiB | +3.3 KiB | 14.3 KiB | +| `/chat` | 669.8 KiB | 672.8 KiB | +3.0 KiB | 14.7 KiB | +| `/subscription` | 658.4 KiB | 662.0 KiB | +3.6 KiB | 20.6 KiB | +| `/tip` | 651.6 KiB | 656.0 KiB | +4.4 KiB | 20.6 KiB | -The direct-import pass replaces root `@/utils` and `@/data/storage` barrel -imports with symbol-level module paths. Turbopack already eliminated most unused -barrel exports, so the immediate size reduction is modest. The explicit paths -keep future barrel exports from silently expanding the client graph. Dexie and -UA Parser remain in the analyzed routes through active feature dependencies and -require separate feature-boundary work to remove from the eager graph. +The earlier direct-import pass replaced root `@/utils` and `@/data/storage` +barrel imports with symbol-level module paths. The current column captures the +full application after the subsequent feature work and is the baseline enforced +by the budgets below. Dexie and UA Parser remain in the analyzed routes through +active feature dependencies and require separate feature-boundary work to +remove from the eager graph. Module loading baseline: @@ -50,9 +54,9 @@ Module loading baseline: The total potential graph can grow slightly because an async chunk has its own loader metadata. The primary budget is the eager graph and the audited module phase, not the sum of eager and unopened async code. -Initial warning budgets are intentionally above the baseline and are not enforced by CI: +The initial budgets are intentionally above the baseline and are enforced by CI: -| Route | Eager client JS warning level | +| Route | Eager client JS budget | | --- | ---: | | `/splash` | 650 KiB | | `/chat` | 675 KiB | diff --git a/package.json b/package.json index 8c5e65c3..08d12345 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "build:start": "pnpm install && pnpm run build && pnpm run start", "lint": "eslint", "lint:fix": "eslint --fix", + "typecheck": "tsc --noEmit", + "quality": "pnpm lint && pnpm typecheck && pnpm test", "test": "vitest run", "test:watch": "vitest", "test:ui": "vitest --ui", @@ -22,6 +24,7 @@ "test:e2e:real": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://frontend-test.banlv-ai.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://proapi.banlv-ai.com} playwright test --project=real-backend", "test:e2e:prod": "E2E_REAL_BACKEND=1 PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL:-https://cozsweet.com} E2E_API_BASE_URL=${E2E_API_BASE_URL:-https://proapi.banlv-ai.com} playwright test --project=prod-smoke", "perf:bundle": "next experimental-analyze --output && node scripts/performance/report-bundle.mjs", + "perf:bundle:check": "PERF_BUNDLE_ENFORCE=1 pnpm perf:bundle", "perf:vitals": "node scripts/performance/collect-web-vitals.mjs", "generate-barrels": "barrelsby --delete -c barrelsby.json" }, diff --git a/scripts/performance/report-bundle.mjs b/scripts/performance/report-bundle.mjs index 1e53184a..d9e17259 100644 --- a/scripts/performance/report-bundle.mjs +++ b/scripts/performance/report-bundle.mjs @@ -14,6 +14,14 @@ const outputPath = resolve( const routes = parseList( process.env.PERF_ROUTES ?? "/splash,/chat,/subscription,/tip", ); +const enforceBudgets = process.env.PERF_BUNDLE_ENFORCE === "1"; + +const eagerClientJsBudgets = new Map([ + ["/splash", 650 * 1024], + ["/chat", 675 * 1024], + ["/subscription", 665 * 1024], + ["/tip", 660 * 1024], +]); const auditTargets = [ { @@ -46,6 +54,19 @@ function main() { const routeReports = routes.map((route) => analyzeRoute(route, asyncBoundariesByPath), ); + const budgetResults = routeReports.flatMap((route) => { + const budgetBytes = eagerClientJsBudgets.get(route.route); + if (budgetBytes === undefined) return []; + + return [ + { + route: route.route, + budgetBytes, + actualBytes: route.eagerClientJs.compressedBytes, + withinBudget: route.eagerClientJs.compressedBytes <= budgetBytes, + }, + ]; + }); const packageJson = JSON.parse( readFileSync(resolve(root, "package.json"), "utf8"), ); @@ -54,6 +75,7 @@ function main() { generatedAt: new Date().toISOString(), nextVersion: packageJson.dependencies.next, routes: routeReports, + eagerClientJsBudgets: budgetResults, }; mkdirSync(dirname(outputPath), { recursive: true }); @@ -65,6 +87,7 @@ function main() { "eager client JS": formatBytes(route.eagerClientJs.compressedBytes), "async client JS": formatBytes(route.asyncClientJs.compressedBytes), "total client JS": formatBytes(route.totalClientJs.compressedBytes), + budget: formatBudget(route, budgetResults), })), ); @@ -84,6 +107,22 @@ function main() { } console.log(`\nBundle summary written to ${outputPath}`); + + if (!enforceBudgets) return; + + const failures = budgetResults.filter((result) => !result.withinBudget); + if (failures.length === 0) { + console.log("All eager client JS budgets passed."); + return; + } + + console.error("\nEager client JS budget exceeded:"); + for (const failure of failures) { + console.error( + `- ${failure.route}: ${formatBytes(failure.actualBytes)} > ${formatBytes(failure.budgetBytes)}`, + ); + } + process.exitCode = 1; } function analyzeRoute(route, boundariesByPath) { @@ -297,6 +336,12 @@ function formatBytes(bytes) { return `${(bytes / 1024).toFixed(1)} KiB`; } +function formatBudget(route, budgetResults) { + const result = budgetResults.find((item) => item.route === route.route); + if (result === undefined) return "not set"; + return `${result.withinBudget ? "pass" : "fail"} / ${formatBytes(result.budgetBytes)}`; +} + class FramedDataFile { constructor(filePath) { this.buffer = readFileSync(filePath); diff --git a/src/data/dto/user/__tests__/user.test.ts b/src/data/dto/user/__tests__/user.test.ts index 58ffe730..5166b678 100644 --- a/src/data/dto/user/__tests__/user.test.ts +++ b/src/data/dto/user/__tests__/user.test.ts @@ -52,6 +52,8 @@ describe("User", () => { platform: "web", country: "Hong Kong", countryCode: "HK", + fbAsid: "", + fbPsid: "", intimacy: 0, dolBalance: 0, creditBalance: 0, diff --git a/src/data/dto/user/user.ts b/src/data/dto/user/user.ts index 6a0932b7..bd386cdb 100644 --- a/src/data/dto/user/user.ts +++ b/src/data/dto/user/user.ts @@ -15,6 +15,8 @@ export class User { declare readonly platform: string; declare readonly country: string; declare readonly countryCode: string; + declare readonly fbAsid: string; + declare readonly fbPsid: string; declare readonly intimacy: number; declare readonly dolBalance: number; declare readonly creditBalance: number; diff --git a/vitest.config.ts b/vitest.config.ts index 5448ec0a..badce874 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,26 @@ import { defineConfig } from "vitest/config"; import path from "node:path"; +import { allowedNodeEnvironmentFlags } from "node:process"; + +const baseExclude = [ + "**/node_modules/**", + "**/dist/**", + "**/.next/**", + "e2e/**", +]; + +const browserTestFiles = [ + "src/data/repositories/__tests__/chat_media_cache_coordinator.test.ts", + "src/lib/analytics/__tests__/behavior_analytics.test.ts", + "src/utils/__tests__/result.test.ts", + "src/utils/__tests__/pwa.test.ts", +]; + +const workerExecArgv = allowedNodeEnvironmentFlags.has( + "--no-experimental-webstorage", +) + ? ["--no-experimental-webstorage"] + : []; export default defineConfig({ resolve: { @@ -8,8 +29,27 @@ export default defineConfig({ }, }, test: { - environment: "jsdom", - exclude: ["**/node_modules/**", "**/dist/**", "**/.next/**", "e2e/**"], - globals: true, + projects: [ + { + extends: true, + test: { + name: "node", + environment: "node", + execArgv: workerExecArgv, + include: ["src/**/*.{test,spec}.ts"], + exclude: [...baseExclude, ...browserTestFiles], + }, + }, + { + extends: true, + test: { + name: "jsdom", + environment: "jsdom", + execArgv: workerExecArgv, + include: ["src/**/*.{test,spec}.tsx", ...browserTestFiles], + exclude: baseExclude, + }, + }, + ], }, });