ci: establish trusted quality baseline

This commit is contained in:
2026-07-15 17:34:17 +08:00
parent c277b3e6ca
commit 86ffbbcdbc
7 changed files with 136 additions and 17 deletions
+23
View File
@@ -8,8 +8,31 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: 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: publish:
name: Build and Push Docker Image name: Build and Push Docker Image
needs: quality
runs-on: gitea-label runs-on: gitea-label
container: container:
image: cozsweet-act-runner-node24-docker:latest image: cozsweet-act-runner-node24-docker:latest
+18 -14
View File
@@ -1,6 +1,6 @@
# Performance baseline # Performance baseline
Baseline date: 2026-07-14 Baseline date: 2026-07-15
Next.js: 16.2.7 (Turbopack) Next.js: 16.2.7 (Turbopack)
@@ -15,8 +15,12 @@ Run:
```bash ```bash
pnpm perf:bundle 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: The command uses the built-in Next.js Turbopack analyzer and writes:
- Interactive analysis: `.next/diagnostics/analyze/` - 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. 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 | | `/splash` | 646.2 KiB | 649.5 KiB | +3.3 KiB | 14.3 KiB |
| `/chat` | 670.5 KiB | 669.8 KiB | -0.7 KiB | 14.7 KiB | | `/chat` | 669.8 KiB | 672.8 KiB | +3.0 KiB | 14.7 KiB |
| `/subscription` | 660.1 KiB | 658.4 KiB | -1.7 KiB | 20.6 KiB | | `/subscription` | 658.4 KiB | 662.0 KiB | +3.6 KiB | 20.6 KiB |
| `/tip` | 653.3 KiB | 651.6 KiB | -1.7 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 The earlier direct-import pass replaced root `@/utils` and `@/data/storage`
imports with symbol-level module paths. Turbopack already eliminated most unused barrel imports with symbol-level module paths. The current column captures the
barrel exports, so the immediate size reduction is modest. The explicit paths full application after the subsequent feature work and is the baseline enforced
keep future barrel exports from silently expanding the client graph. Dexie and by the budgets below. Dexie and UA Parser remain in the analyzed routes through
UA Parser remain in the analyzed routes through active feature dependencies and active feature dependencies and require separate feature-boundary work to
require separate feature-boundary work to remove from the eager graph. remove from the eager graph.
Module loading baseline: 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. 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 | | `/splash` | 650 KiB |
| `/chat` | 675 KiB | | `/chat` | 675 KiB |
+3
View File
@@ -12,6 +12,8 @@
"build:start": "pnpm install && pnpm run build && pnpm run start", "build:start": "pnpm install && pnpm run build && pnpm run start",
"lint": "eslint", "lint": "eslint",
"lint:fix": "eslint --fix", "lint:fix": "eslint --fix",
"typecheck": "tsc --noEmit",
"quality": "pnpm lint && pnpm typecheck && pnpm test",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:ui": "vitest --ui", "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: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", "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": "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", "perf:vitals": "node scripts/performance/collect-web-vitals.mjs",
"generate-barrels": "barrelsby --delete -c barrelsby.json" "generate-barrels": "barrelsby --delete -c barrelsby.json"
}, },
+45
View File
@@ -14,6 +14,14 @@ const outputPath = resolve(
const routes = parseList( const routes = parseList(
process.env.PERF_ROUTES ?? "/splash,/chat,/subscription,/tip", 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 = [ const auditTargets = [
{ {
@@ -46,6 +54,19 @@ function main() {
const routeReports = routes.map((route) => const routeReports = routes.map((route) =>
analyzeRoute(route, asyncBoundariesByPath), 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( const packageJson = JSON.parse(
readFileSync(resolve(root, "package.json"), "utf8"), readFileSync(resolve(root, "package.json"), "utf8"),
); );
@@ -54,6 +75,7 @@ function main() {
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
nextVersion: packageJson.dependencies.next, nextVersion: packageJson.dependencies.next,
routes: routeReports, routes: routeReports,
eagerClientJsBudgets: budgetResults,
}; };
mkdirSync(dirname(outputPath), { recursive: true }); mkdirSync(dirname(outputPath), { recursive: true });
@@ -65,6 +87,7 @@ function main() {
"eager client JS": formatBytes(route.eagerClientJs.compressedBytes), "eager client JS": formatBytes(route.eagerClientJs.compressedBytes),
"async client JS": formatBytes(route.asyncClientJs.compressedBytes), "async client JS": formatBytes(route.asyncClientJs.compressedBytes),
"total client JS": formatBytes(route.totalClientJs.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}`); 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) { function analyzeRoute(route, boundariesByPath) {
@@ -297,6 +336,12 @@ function formatBytes(bytes) {
return `${(bytes / 1024).toFixed(1)} KiB`; 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 { class FramedDataFile {
constructor(filePath) { constructor(filePath) {
this.buffer = readFileSync(filePath); this.buffer = readFileSync(filePath);
+2
View File
@@ -52,6 +52,8 @@ describe("User", () => {
platform: "web", platform: "web",
country: "Hong Kong", country: "Hong Kong",
countryCode: "HK", countryCode: "HK",
fbAsid: "",
fbPsid: "",
intimacy: 0, intimacy: 0,
dolBalance: 0, dolBalance: 0,
creditBalance: 0, creditBalance: 0,
+2
View File
@@ -15,6 +15,8 @@ export class User {
declare readonly platform: string; declare readonly platform: string;
declare readonly country: string; declare readonly country: string;
declare readonly countryCode: string; declare readonly countryCode: string;
declare readonly fbAsid: string;
declare readonly fbPsid: string;
declare readonly intimacy: number; declare readonly intimacy: number;
declare readonly dolBalance: number; declare readonly dolBalance: number;
declare readonly creditBalance: number; declare readonly creditBalance: number;
+43 -3
View File
@@ -1,5 +1,26 @@
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
import path from "node:path"; 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({ export default defineConfig({
resolve: { resolve: {
@@ -8,8 +29,27 @@ export default defineConfig({
}, },
}, },
test: { test: {
environment: "jsdom", projects: [
exclude: ["**/node_modules/**", "**/dist/**", "**/.next/**", "e2e/**"], {
globals: true, 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,
},
},
],
}, },
}); });