diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2cad8f11a..5af10a00c 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -64,8 +64,20 @@ jobs: node-version: latest - run: bun install - run: bun run build + # Every row is the median of the same number of cold samples taken in + # rotating order (benchmark-manifest.json). The run goes to an artifact and + # the Devup UI rows are compared with the checked baseline at the end of the + # job, so the profile and the Rust measurement still run when it fails. - name: Benchmark run: bun run benchmark + - name: Profile Devup UI Turbopack + env: + DEVUP_UI_PROFILE: "1" + run: | + rm -rf benchmark/next-devup-ui-single-turbo/.next \ + benchmark/next-devup-ui-single-turbo/df \ + benchmark/next-devup-ui-single-turbo/tsconfig.tsbuildinfo + bun run --filter next-devup-ui-single-turbo-benchmark build - name: Upload the measured run uses: actions/upload-artifact@v7 if: ${{ !cancelled() }} @@ -74,14 +86,142 @@ jobs: path: benchmark-run.json if-no-files-found: ignore retention-days: 30 - - name: Profile Devup UI Turbopack + - name: Measure the SemanticBuilder passes of the extractor + run: cargo bench -p extractor --bench semantic_pass_benchmark -- --quick + - name: Compare the Devup UI rows with the baseline + run: bun benchmark-check.js + + wasm-size: + runs-on: ubuntu-latest + timeout-minutes: 60 + permissions: + contents: read + env: + CARGO_BUILD_JOBS: "2" + RUSTUP_TOOLCHAIN: stable + steps: + - name: Checkout the tested source and both histories + uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions-rust-lang/setup-rust-toolchain@v2 + with: + toolchain: stable + target: wasm32-unknown-unknown + cache: false + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - uses: actions/setup-node@v7 + with: + node-version: latest + - name: Resolve and report exact WASM comparison sources + run: node resolve-wasm-size-sources.js + - uses: jetli/wasm-pack-action@v0.4.0 + with: + version: latest + - name: Cache registry downloads only + uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: cargo-wasm-size-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }} + - name: Install the latest shared installer and optimizer once env: - DEVUP_UI_PROFILE: "1" + GH_TOKEN: ${{ github.token }} run: | - rm -rf benchmark/next-devup-ui-single-turbo/.next \ - benchmark/next-devup-ui-single-turbo/df \ - benchmark/next-devup-ui-single-turbo/tsconfig.tsbuildinfo - bun run --filter next-devup-ui-single-turbo-benchmark build + set -euo pipefail + curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash + mkdir -p "$RUNNER_TEMP/wasm-size-tools/binaryen" + gh release download --repo WebAssembly/binaryen --pattern '*-x86_64-linux.tar.gz' --dir "$RUNNER_TEMP/wasm-size-tools" + tar -xzf "$RUNNER_TEMP"/wasm-size-tools/*.tar.gz --strip-components=1 -C "$RUNNER_TEMP/wasm-size-tools/binaryen" + echo "$RUNNER_TEMP/wasm-size-tools/binaryen/bin" >> "$GITHUB_PATH" + - name: Build exact sources with shared tools and lock-matched bindgen + run: | + set -euo pipefail + export SIZE_ROOT="$RUNNER_TEMP/wasm-size" + mkdir -p "$SIZE_ROOT" + stage=source + trap 'echo "## WASM size: FAIL - $stage build or provenance failed; no comparison was skipped or substituted." >> "$GITHUB_STEP_SUMMARY"' ERR + for side in base candidate; do + if [[ "$side" == base ]]; then sha="$BASE_SHA"; else sha="$CANDIDATE_SHA"; fi + git cat-file -e "$sha^{commit}" + mkdir -p "$SIZE_ROOT/$side" + git archive "$sha" | tar -x -C "$SIZE_ROOT/$side" + done + cat > "$SIZE_ROOT/provenance.mjs" <<'NODE' + import { execFileSync } from 'node:child_process' + import { createHash } from 'node:crypto' + import { readFileSync, writeFileSync } from 'node:fs' + import { join } from 'node:path' + const run = (command, args) => execFileSync(command, args, { encoding: 'utf8' }).trim() + const tools = Object.fromEntries([ + ['rustc', 'rustc', ['--version', '--verbose']], + ['wasmPack', 'wasm-pack', ['--version']], + ['wasmBindgen', 'wasm-bindgen', ['--version']], + ['wasmOpt', 'wasm-opt', ['--version']], + ['bun', 'bun', ['--version']], + ['node', 'node', ['--version']], + ].map(([key, command, args]) => { + const path = run('which', [command]) + const hash = createHash('sha256').update(readFileSync(path)).digest('hex') + return [key, `${run(command, args)}\n${path}\nsha256:${hash}`] + })) + const side = process.argv[2] + const file = join(process.env.SIZE_ROOT, 'metadata.json') + const metadata = side === 'base' ? { + jobId: `${process.env.GITHUB_RUN_ID}/${process.env.GITHUB_RUN_ATTEMPT}/${process.env.GITHUB_JOB}`, + buildCommand: 'cargo metadata --locked --format-version 1 > /dev/null && wasm-pack build --mode no-install --release --target nodejs --out-dir ./pkg --out-name index --locked && node script.js', + } : JSON.parse(readFileSync(file, 'utf8')) + const sha = side === 'base' ? process.env.BASE_SHA : process.env.CANDIDATE_SHA + metadata[side] = { + commit: run('git', ['rev-parse', `${sha}^{commit}`]), + tree: run('git', ['rev-parse', `${sha}^{tree}`]), + wasmBindgenVersion: process.env.WASM_BINDGEN_VERSION, + packageDir: join(process.env.SIZE_ROOT, side, 'bindings/devup-ui-wasm/pkg'), tools, + } + writeFileSync(file, JSON.stringify(metadata, null, 2) + '\n') + NODE + shared_path="$PATH" + for side in base candidate; do + stage="$side" + ( + WASM_BINDGEN_VERSION="$(bun resolve-wasm-bindgen.js "$SIZE_ROOT/$side/Cargo.lock")" + export WASM_BINDGEN_VERSION + bindgen_root="$SIZE_ROOT/abi-tools/$WASM_BINDGEN_VERSION" + if [[ ! -x "$bindgen_root/bin/wasm-bindgen" ]]; then + cargo binstall wasm-bindgen-cli --version "=$WASM_BINDGEN_VERSION" --root "$bindgen_root" --no-confirm --force + fi + export PATH="$bindgen_root/bin:$shared_path" + node "$SIZE_ROOT/provenance.mjs" "$side" + cd "$SIZE_ROOT/$side/bindings/devup-ui-wasm" + export CARGO_TARGET_DIR="$SIZE_ROOT/$side-target" + cargo metadata --locked --format-version 1 > /dev/null + wasm-pack build --mode no-install --release --target nodejs --out-dir ./pkg --out-name index --locked + node script.js + ) 2>&1 | tee "$SIZE_ROOT/$side-build.log" + done + - name: Compare all four relative size metrics + run: | + node verify-wasm-size.js \ + --base-pkg "$RUNNER_TEMP/wasm-size/base/bindings/devup-ui-wasm/pkg" \ + --candidate-pkg "$RUNNER_TEMP/wasm-size/candidate/bindings/devup-ui-wasm/pkg" \ + --metadata "$RUNNER_TEMP/wasm-size/metadata.json" \ + --report "$RUNNER_TEMP/wasm-size/wasm-size.json" + - name: Upload size measurements and build evidence + uses: actions/upload-artifact@v7 + if: ${{ !cancelled() }} + with: + name: wasm-size + path: | + ${{ runner.temp }}/wasm-size/metadata.json + ${{ runner.temp }}/wasm-size/wasm-size.json + ${{ runner.temp }}/wasm-size/*-build.log + if-no-files-found: error + retention-days: 30 # The landing app is the only real-scale acceptance target in this repo, and # `publish` runs it against the deployed vinext/@devup-ui/vite-plugin build. diff --git a/.gitignore b/.gitignore index 7e5ea71be..0eb1f2217 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ playwright-report .omo /.changepacks/publish.tgz benchmark-run.json +wasm-size.json diff --git a/README.md b/README.md index 49e4e4606..eceb7305d 100644 --- a/README.md +++ b/README.md @@ -80,33 +80,33 @@ npm install @devup-ui/webpack-plugin ## Comparison Benchmarks -[Latest CI benchmark](https://github.com/dev-five-git/devup-ui/actions/runs/36702867475) (commit `8d6c2f62`, Devup UI 1.0.44) on `ubuntu-24.04` with Next.js 16.3.3. All Next.js builds use the native TypeScript 7 CLI for type checking. The English and Korean tables are generated from the same checked result file, `benchmark-results.json`. +[Latest CI benchmark](https://github.com/dev-five-git/devup-ui/actions/runs/37758611477) (commit `467a379a`, Devup UI 1.0.44) on `ubuntu-24.04` with Next.js 16.3.3. Every row is the median of 3 cold builds that ran in rotating order, shown with the range of the samples. All Next.js builds use the native TypeScript 7 CLI for type checking. The English and Korean tables are generated from the same checked result file, `benchmark-results.json`. -Build Size is every byte of the build output directory (`.next`, or `dist` for vinext); CSS Size is the emitted `.css` files alone, measured separately. +Build Size is every byte of the build output directory (`.next`, or `dist` for vinext), including caches and server output. The checked table retains its original historical CSS measurements, which recursively counted `.css` files throughout that output. New runs count only browser-shipped CSS: `.next/static/**/*.css` (Webpack `static/css` and Turbopack `static/chunks`) or vinext `dist/client/**/*.css`, excluding caches and server CSS. A missing browser-assets directory is an error; an existing directory with no CSS counts zero. Approved CSS measurement corrections are limited to explicitly named `devup-ui`, `devup-ui-single` and `vinext-devup-ui` rows. `bun benchmark-check.js --write-css-baseline devup-ui` retains the per-file mode; `bun benchmark-check.js --write-css-baseline devup-ui-single,vinext-devup-ui` selects only the other two rows in one comma-separated argument. Only selected existing rows' CSS bytes change; unlike `--write-baseline`, this preserves sample count, build bytes, ratios, tolerances, all unselected rows and calibration history. Missing or unsupported names and conflicting writer modes are rejected. -Webpack values are one cold build: +Webpack: -| Library | Version | Build Time | Build Size | CSS Size | -| --------------------------- | ------- | ---------- | ----------------- | ------------ | -| tailwindcss | 4.3.3 | 16.67s | 67,332,642 bytes | 5,852 bytes | -| styleX | 0.19.0 | 38.08s | 96,291,287 bytes | 425 bytes | -| vanilla-extract | 1.21.2 | 16.09s | 68,565,036 bytes | 294 bytes | -| kuma-ui | 1.6.4 | 17.91s | 75,627,783 bytes | 340 bytes | -| panda-css | 1.12.0 | 18.16s | 71,849,418 bytes | 15,615 bytes | -| chakra-ui | 3.37.0 | 27.22s | 207,426,274 bytes | 0 bytes | -| mui | 9.4.0 | 18.78s | 101,473,260 bytes | 0 bytes | -| **devup-ui (per-file CSS)** | 1.0.44 | **14.27s** | 67,424,897 bytes | 562 bytes | -| **devup-ui (single CSS)** | 1.0.44 | **14.17s** | 67,426,959 bytes | 790 bytes | +| Library | Version | Median Build Time | Range | Build Size | CSS Size | +| --------------------------- | ------- | ----------------- | --------------- | ----------------- | ------------ | +| tailwindcss | 4.3.3 | 13.02s | 12.91s - 13.20s | 67,339,250 bytes | 5,852 bytes | +| styleX | 0.19.0 | 29.01s | 28.72s - 29.50s | 96,290,533 bytes | 425 bytes | +| vanilla-extract | 1.21.2 | 12.34s | 12.22s - 12.35s | 68,565,511 bytes | 294 bytes | +| kuma-ui | 1.6.4 | 13.55s | 13.49s - 13.57s | 75,627,429 bytes | 340 bytes | +| panda-css | 1.12.0 | 13.44s | 13.29s - 13.58s | 71,849,243 bytes | 15,615 bytes | +| chakra-ui | 3.37.0 | 20.29s | 19.95s - 21.06s | 207,425,028 bytes | 0 bytes | +| mui | 9.4.0 | 14.12s | 13.78s - 14.26s | 101,476,098 bytes | 0 bytes | +| **devup-ui (per-file CSS)** | 1.0.44 | **10.95s** | 10.87s - 11.09s | 67,427,695 bytes | 562 bytes | +| **devup-ui (single CSS)** | 1.0.44 | **11.04s** | 10.88s - 11.40s | 67,436,336 bytes | 790 bytes | -Turbopack values are medians of six cold builds in alternating order: +Turbopack: -| Library | Version | Median Build Time | Build Size | CSS Size | -| -------------------------------------- | ------- | ----------------- | ---------------- | ----------- | -| tailwindcss | 4.3.3 | 6.98s | 38,422,141 bytes | 6,197 bytes | -| **devup-ui (direct APIs, single CSS)** | 1.0.44 | **6.93s** | 36,518,952 bytes | 327 bytes | -| **devup-ui (static `.css.ts`)** | 1.0.44 | **6.92s** | 36,578,506 bytes | 268 bytes | +| Library | Version | Median Build Time | Range | Build Size | CSS Size | +| -------------------------------------- | ------- | ----------------- | ------------- | ---------------- | ----------- | +| tailwindcss | 4.3.3 | 5.68s | 5.51s - 5.73s | 38,440,013 bytes | 6,197 bytes | +| **devup-ui (direct APIs, single CSS)** | 1.0.44 | **5.79s** | 5.77s - 5.87s | 36,538,765 bytes | 327 bytes | +| **devup-ui (static `.css.ts`)** | 1.0.44 | **5.71s** | 5.67s - 5.99s | 36,580,207 bytes | 268 bytes | -The Turbopack ranges overlap, so the direct-API median is 0.05s (0.7%) ahead of Tailwind on this fixture and the two are effectively at parity. The six cold samples were Tailwind `7.10s, 6.99s, 6.98s, 6.90s, 6.91s, 6.97s`, direct Devup UI `6.98s, 6.83s, 6.95s, 6.91s, 6.90s, 6.94s`, and static `.css.ts` `6.93s, 6.95s, 6.85s, 6.97s, 6.87s, 6.91s`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. Every `.css.ts` module runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. +The Turbopack ranges do not overlap, so the direct-API median is 0.12s (2.1%) behind Tailwind on this fixture. The 3 cold samples were Tailwind `5.68s, 5.73s, 5.51s`, direct Devup UI `5.77s, 5.79s, 5.87s`, and static `.css.ts` `5.71s, 5.99s, 5.67s`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. Every `.css.ts` module runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. CI compares the Devup UI rows with a checked baseline (see `benchmark-gate.js` for the rule) and fails on a regression beyond the measured noise. ## How it works diff --git a/README_ko.md b/README_ko.md index ff9ca962a..85d3d2a83 100644 --- a/README_ko.md +++ b/README_ko.md @@ -80,33 +80,33 @@ npm install @devup-ui/webpack-plugin ## 비교 벤치마크 -[최신 CI 벤치마크](https://github.com/dev-five-git/devup-ui/actions/runs/36702867475) (커밋 `8d6c2f62`, Devup UI 1.0.44)는 `ubuntu-24.04`에서 Next.js 16.3.3로 실행했습니다. 모든 Next.js 빌드는 네이티브 TypeScript 7 CLI로 타입 검사를 수행합니다. 영어와 한국어 표는 같은 결과 파일 `benchmark-results.json`에서 생성합니다. +[최신 CI 벤치마크](https://github.com/dev-five-git/devup-ui/actions/runs/37758611477) (커밋 `467a379a`, Devup UI 1.0.44)는 `ubuntu-24.04`에서 Next.js 16.3.3로 실행했습니다. 모든 행은 순서를 돌려 가며 측정한 cold build 3회의 중앙값이며 샘플의 범위를 함께 보여줍니다. 모든 Next.js 빌드는 네이티브 TypeScript 7 CLI로 타입 검사를 수행합니다. 영어와 한국어 표는 같은 결과 파일 `benchmark-results.json`에서 생성합니다. -빌드 사이즈는 빌드 출력 디렉터리(`.next`, vinext는 `dist`)의 모든 바이트이고, CSS 사이즈는 출력된 `.css` 파일만 따로 잰 값입니다. +빌드 사이즈는 캐시와 서버 출력을 포함한 빌드 출력 디렉터리(`.next`, vinext는 `dist`)의 모든 바이트입니다. 체크인된 표는 출력 전체의 `.css` 파일을 재귀적으로 합산한 기존 CSS 측정값을 그대로 보존합니다. 새 실행은 브라우저에 제공되는 CSS만 측정합니다: `.next/static/**/*.css`(Webpack `static/css`, Turbopack `static/chunks`) 또는 vinext `dist/client/**/*.css`. 캐시와 서버 CSS는 제외합니다. 브라우저 에셋 디렉터리가 없으면 오류이며, 존재하지만 CSS가 없으면 0입니다. 승인된 CSS 측정 보정은 명시적으로 지정한 `devup-ui`, `devup-ui-single`, `vinext-devup-ui` 행으로 제한됩니다. `bun benchmark-check.js --write-css-baseline devup-ui`는 기존 per-file 모드를 유지하며, `bun benchmark-check.js --write-css-baseline devup-ui-single,vinext-devup-ui`는 쉼표로 구분한 인자 하나로 나머지 두 행만 선택합니다. 선택한 기존 행의 CSS 바이트만 바꾸고, `--write-baseline`과 달리 샘플 수, 빌드 바이트, 비율, 허용 오차, 선택하지 않은 모든 행과 보정 이력을 보존합니다. 이름 누락, 지원하지 않는 이름, 다른 쓰기 모드와의 혼용은 오류입니다. -Webpack 값은 cold build 1회 결과입니다. +Webpack: -| 라이브러리 | 버전 | 빌드 시간 | 빌드 사이즈 | CSS 사이즈 | -| --------------------------- | ------ | ---------- | ----------------- | ------------ | -| tailwindcss | 4.3.3 | 16.67s | 67,332,642 bytes | 5,852 bytes | -| styleX | 0.19.0 | 38.08s | 96,291,287 bytes | 425 bytes | -| vanilla-extract | 1.21.2 | 16.09s | 68,565,036 bytes | 294 bytes | -| kuma-ui | 1.6.4 | 17.91s | 75,627,783 bytes | 340 bytes | -| panda-css | 1.12.0 | 18.16s | 71,849,418 bytes | 15,615 bytes | -| chakra-ui | 3.37.0 | 27.22s | 207,426,274 bytes | 0 bytes | -| mui | 9.4.0 | 18.78s | 101,473,260 bytes | 0 bytes | -| **devup-ui (per-file CSS)** | 1.0.44 | **14.27s** | 67,424,897 bytes | 562 bytes | -| **devup-ui (single CSS)** | 1.0.44 | **14.17s** | 67,426,959 bytes | 790 bytes | +| 라이브러리 | 버전 | 빌드 시간 중앙값 | 범위 | 빌드 사이즈 | CSS 사이즈 | +| --------------------------- | ------ | ---------- | --------------- | ----------------- | ------------ | +| tailwindcss | 4.3.3 | 13.02s | 12.91s - 13.20s | 67,339,250 bytes | 5,852 bytes | +| styleX | 0.19.0 | 29.01s | 28.72s - 29.50s | 96,290,533 bytes | 425 bytes | +| vanilla-extract | 1.21.2 | 12.34s | 12.22s - 12.35s | 68,565,511 bytes | 294 bytes | +| kuma-ui | 1.6.4 | 13.55s | 13.49s - 13.57s | 75,627,429 bytes | 340 bytes | +| panda-css | 1.12.0 | 13.44s | 13.29s - 13.58s | 71,849,243 bytes | 15,615 bytes | +| chakra-ui | 3.37.0 | 20.29s | 19.95s - 21.06s | 207,425,028 bytes | 0 bytes | +| mui | 9.4.0 | 14.12s | 13.78s - 14.26s | 101,476,098 bytes | 0 bytes | +| **devup-ui (per-file CSS)** | 1.0.44 | **10.95s** | 10.87s - 11.09s | 67,427,695 bytes | 562 bytes | +| **devup-ui (single CSS)** | 1.0.44 | **11.04s** | 10.88s - 11.40s | 67,436,336 bytes | 790 bytes | -Turbopack 값은 실행 순서를 번갈아 측정한 cold build 6회의 중앙값입니다. +Turbopack: -| 라이브러리 | 버전 | 빌드 시간 중앙값 | 빌드 사이즈 | CSS 사이즈 | -| -------------------------------------- | ------ | --------- | ---------------- | ----------- | -| tailwindcss | 4.3.3 | 6.98s | 38,422,141 bytes | 6,197 bytes | -| **devup-ui (direct APIs, single CSS)** | 1.0.44 | **6.93s** | 36,518,952 bytes | 327 bytes | -| **devup-ui (static `.css.ts`)** | 1.0.44 | **6.92s** | 36,578,506 bytes | 268 bytes | +| 라이브러리 | 버전 | 빌드 시간 중앙값 | 범위 | 빌드 사이즈 | CSS 사이즈 | +| -------------------------------------- | ------ | --------- | ------------- | ---------------- | ----------- | +| tailwindcss | 4.3.3 | 5.68s | 5.51s - 5.73s | 38,440,013 bytes | 6,197 bytes | +| **devup-ui (direct APIs, single CSS)** | 1.0.44 | **5.79s** | 5.77s - 5.87s | 36,538,765 bytes | 327 bytes | +| **devup-ui (static `.css.ts`)** | 1.0.44 | **5.71s** | 5.67s - 5.99s | 36,580,207 bytes | 268 bytes | -Turbopack 측정 범위는 서로 겹치므로 direct API 중앙값은 이 fixture에서 Tailwind보다 0.05s(0.7%) 빠르며 사실상 동률입니다. cold 샘플 6개는 Tailwind `7.10s, 6.99s, 6.98s, 6.90s, 6.91s, 6.97s`, direct Devup UI `6.98s, 6.83s, 6.95s, 6.91s, 6.90s, 6.94s`, static `.css.ts` `6.93s, 6.95s, 6.85s, 6.97s, 6.87s, 6.91s`입니다. fixture는 앱 구조만 비슷하고 스타일이 픽셀 단위로 같지는 않습니다. Tailwind는 첫 문단과 버튼에 더 많은 스타일을 주고, Devup UI는 타입이 있는 컴포넌트/스타일 props를 사용합니다. 규칙 단위 마이크로벤치마크가 아니라 빌드 파이프라인 결과로 보세요. 모든 `.css.ts`는 full Boa evaluator로 처리하며, 첫 모듈에 약 20ms, 이후 모듈마다 1ms 미만이 더 듭니다. +Turbopack 측정 범위는 겹치지 않으므로 direct API 중앙값은 이 fixture에서 Tailwind보다 0.12s(2.1%) 느리며. cold 샘플 3개는 Tailwind `5.68s, 5.73s, 5.51s`, direct Devup UI `5.77s, 5.79s, 5.87s`, static `.css.ts` `5.71s, 5.99s, 5.67s`입니다. fixture는 앱 구조만 비슷하고 스타일이 픽셀 단위로 같지는 않습니다. Tailwind는 첫 문단과 버튼에 더 많은 스타일을 주고, Devup UI는 타입이 있는 컴포넌트/스타일 props를 사용합니다. 규칙 단위 마이크로벤치마크가 아니라 빌드 파이프라인 결과로 보세요. 모든 `.css.ts`는 full Boa evaluator로 처리하며, 첫 모듈에 약 20ms, 이후 모듈마다 1ms 미만이 더 듭니다. CI는 Devup UI 행을 체크인된 기준값과 비교해 측정된 노이즈를 넘는 회귀에서 실패합니다(규칙은 `benchmark-gate.js`). ## 작동 원리 diff --git a/benchmark-baseline.json b/benchmark-baseline.json new file mode 100644 index 000000000..fae5f447b --- /dev/null +++ b/benchmark-baseline.json @@ -0,0 +1,104 @@ +{ + "samples": 3, + "rows": { + "stylex-turbo-devup-ui": { + "control": "tailwind-turbo", + "ratio": 1.3509, + "calibration": [ + 1.3509, 1.3471, 1.2939, 1.3096, 1.4427, 1.417800722121156, + 1.4176477939108723 + ], + "timeTolerance": 0.22029757939151684, + "bytes": 31230251, + "cssBytes": 321 + }, + "devup-ui": { + "control": "tailwind", + "ratio": 0.856, + "calibration": [ + 0.856, 0.8538, 0.9013, 0.8735, 0.8515, 0.8731578676559654, + 0.8408134770157684 + ], + "timeTolerance": 0.14132365183231688, + "bytes": 71047404, + "cssBytes": 494 + }, + "devup-ui-single": { + "control": "tailwind", + "ratio": 0.8704, + "calibration": [ + 0.85, 0.8727, 0.9024, 0.8704, 0.859, 0.874014974821355, + 0.848021750757629 + ], + "timeTolerance": 0.12495002123706572, + "bytes": 71033778, + "cssBytes": 395 + }, + "devup-ui-single-turbo": { + "control": "tailwind-turbo", + "ratio": 0.9794, + "calibration": [ + 0.9702, 0.9794, 0.9701, 1.003, 0.9981, 0.9713218261211682, + 1.0209678104409452 + ], + "timeTolerance": 0.10387545526025166, + "bytes": 36538765, + "cssBytes": 327 + }, + "devup-ui-turbo": { + "control": "tailwind-turbo", + "ratio": 0.9901, + "calibration": [ + 0.9901, 1, 0.9753, 1.0089, 0.9885, 0.9761607019540564, + 1.0183680359603602 + ], + "timeTolerance": 0.1, + "bytes": 36541732, + "cssBytes": 358 + }, + "vanilla-extract-devup-ui": { + "control": "tailwind-turbo", + "ratio": 0.9882, + "calibration": [ + 0.9929, 0.9882, 0.9428, 0.9867, 1.0439, 0.9471234772686924, + 1.0068141946392295 + ], + "timeTolerance": 0.20461445051609004, + "bytes": 36580207, + "cssBytes": 268 + }, + "tailwind-turbo-devup-ui": { + "control": "tailwind-turbo", + "ratio": 0.9986, + "calibration": [ + 0.9986, 1.0059, 0.9532, 0.997, 1.0115, 0.9732950875989826, + 1.0262587718287204 + ], + "timeTolerance": 0.1463223950104553, + "bytes": 36593833, + "cssBytes": 1491 + }, + "vinext-devup-ui": { + "control": "tailwind", + "ratio": 0.1962, + "calibration": [ + 0.1962, 0.2066, 0.2104, 0.1995, 0.1868, 0.1894402619634694, + 0.1880300744715731 + ], + "timeTolerance": 0.2405708460754333, + "bytes": 1248050, + "cssBytes": 14873 + }, + "devup-ui-collapse": { + "control": "tailwind-turbo", + "ratio": 1.0118, + "calibration": [ + 1.0384, 1.0118, 0.9519, 1.0015, 1.021, 0.9659282187960732, + 1.0242688078238835 + ], + "timeTolerance": 0.17098240759043293, + "bytes": 31849350, + "cssBytes": 1286 + } + } +} diff --git a/benchmark-check.js b/benchmark-check.js new file mode 100644 index 000000000..37d3897c6 --- /dev/null +++ b/benchmark-check.js @@ -0,0 +1,84 @@ +// Compares benchmark-run.json (written by `bun benchmark.js`) with the checked +// baseline, and fails on a regression. The rule is in benchmark-gate.js. +// +// bun benchmark-check.js compare, exit 1 on a regression +// bun benchmark-check.js --write-baseline add this run to the calibration and rewrite benchmark-baseline.json +// bun benchmark-check.js --write-css-baseline devup-ui replace only the existing per-file CSS baseline (original mode) +// bun benchmark-check.js --write-css-baseline devup-ui-single,vinext-devup-ui replace only these two CSS fields +// CSS-only targets are limited to devup-ui, devup-ui-single and vinext-devup-ui; +// keep all calibration, time and build-size fields, never select rows implicitly. +// bun benchmark-check.js --write-results rewrite benchmark-results.json (the README tables) from this run +// +// A change that makes a build slower or larger on purpose runs the two writes +// on a CI run's `benchmark-run` artifact and commits the files in the same pull +// request. +import { readFileSync, writeFileSync } from 'node:fs' + +import { + evaluate, + nextBaseline, + nextCssBaseline, + report, +} from './benchmark-gate.js' + +const read = (file) => JSON.parse(readFileSync(file, 'utf8')) +const write = (file, value) => + writeFileSync(file, JSON.stringify(value, null, 2) + '\n') + +const manifest = read('benchmark-manifest.json') +const run = read('benchmark-run.json') + +if (process.argv.includes('--write-css-baseline')) { + const target = process.argv[process.argv.indexOf('--write-css-baseline') + 1] + if (!target || process.argv.length !== 4) + throw new TypeError( + 'Use --write-css-baseline with one comma-separated target argument and no other writer modes', + ) + const previous = read('benchmark-baseline.json') + write( + 'benchmark-baseline.json', + nextCssBaseline(run, previous, target.split(',')), + ) + console.info('benchmark-baseline.json named CSS baselines rewritten') +} else if (process.argv.includes('--write-baseline')) { + let previous + try { + previous = read('benchmark-baseline.json') + } catch { + previous = undefined + } + write('benchmark-baseline.json', nextBaseline(run, manifest, previous)) + console.info('benchmark-baseline.json rewritten') +} else if (process.argv.includes('--write-results')) { + const results = read('benchmark-results.json') + const runId = run.run.id ?? results.run.id + results.run = { + ...results.run, + id: runId, + url: `https://github.com/dev-five-git/devup-ui/actions/runs/${runId}`, + commit: (run.run.commit ?? results.run.commit).slice(0, 8), + } + results.samples = run.samples + results.rows = results.rows.map((row) => ({ + ...row, + samples: run.rows[row.target].samples, + median: run.rows[row.target].median, + min: run.rows[row.target].min, + max: run.rows[row.target].max, + bytes: run.rows[row.target].bytes, + cssBytes: run.rows[row.target].cssBytes, + })) + write('benchmark-results.json', results) + console.info('benchmark-results.json rewritten') +} else { + const baseline = read('benchmark-baseline.json') + const evaluation = evaluate(run, baseline) + console.info(report(run, baseline, evaluation)) + if (evaluation.failed) { + console.error( + '\nbenchmark-check: a Devup UI row regressed beyond the measured noise (rule: benchmark-gate.js). If the change is intended, add this run to the baseline with `bun benchmark-check.js --write-baseline` in the same pull request.', + ) + process.exit(1) + } + console.info('\nbenchmark-check: no regression beyond the measured noise') +} diff --git a/benchmark-gate.js b/benchmark-gate.js new file mode 100644 index 000000000..07066a47f --- /dev/null +++ b/benchmark-gate.js @@ -0,0 +1,156 @@ +// The regression rule behind `benchmark-check.js`, kept free of file and process +// access so it can be tested. +// +// Only the Devup UI rows of benchmark-manifest.json (`gate: true`) can fail; +// every other library's row is informational. +// +// Build time is compared as a ratio to a control row measured in the same run +// (Tailwind on the same bundler), because the shared CI runner drifts by about +// 25% between runs while the ratio drifts by a few percent. A row fails when +// ratio > baseline ratio * (1 + timeTolerance) +// where the tolerance is at least 10% and at least twice the spread the ratio +// showed over the calibration runs stored with the baseline, so ordinary noise +// stays well inside it and a real slowdown does not. +// +// Output sizes are deterministic to about 0.05% between runs. A row fails when +// its bytes grow beyond baseline * 1.01, or its CSS bytes beyond the baseline +// plus the larger of 5% and 32 bytes. A change that grows a size on purpose +// updates benchmark-baseline.json in the same pull request. + +export const MIN_TIME_TOLERANCE = 0.1 +export const SPREAD_FACTOR = 2 +export const BYTES_TOLERANCE = 0.01 +export const CSS_TOLERANCE = 0.05 +export const CSS_MIN_SLACK = 32 +export const KEPT_CALIBRATION_RUNS = 10 + +export function median(values) { + const sorted = values.toSorted((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 + ? sorted[middle] + : (sorted[middle - 1] + sorted[middle]) / 2 +} + +/** The targets in the order of one round: the list, started +ound steps of its length / rounds further on */ +export function roundOrder(names, round, rounds) { + const offset = Math.floor((names.length * round) / rounds) + return [...names.slice(offset), ...names.slice(0, offset)] +} + +/** (max - min) / median */ +export function relativeSpread(values) { + return (Math.max(...values) - Math.min(...values)) / median(values) +} + +/** The time of a row as a ratio to the control of its group in the same run */ +export function timeRatio(run, target, control) { + return run.rows[target].median / run.rows[control].median +} + +export function timeTolerance(calibration) { + return Math.max( + MIN_TIME_TOLERANCE, + SPREAD_FACTOR * relativeSpread(calibration), + ) +} + +/** Checks the run against the baseline: one entry per gated row, and whether any failed */ +export function evaluate(run, baseline) { + const results = [] + if (run.samples !== baseline.samples) + results.push({ + target: '(policy)', + failures: [ + `the run took ${run.samples} samples per row, the baseline was made with ${baseline.samples}`, + ], + }) + for (const [target, expected] of Object.entries(baseline.rows)) { + const row = run.rows[target] + const failures = [] + if (!row || !run.rows[expected.control]) { + failures.push('missing from the run') + } else { + const ratio = timeRatio(run, target, expected.control) + const limit = expected.ratio * (1 + expected.timeTolerance) + if (ratio > limit) + failures.push( + `build time is ${ratio.toFixed(3)}x ${expected.control}, over the limit ${limit.toFixed(3)}x (baseline ${expected.ratio.toFixed(3)}x + ${(expected.timeTolerance * 100).toFixed(0)}%)`, + ) + const bytesLimit = Math.floor(expected.bytes * (1 + BYTES_TOLERANCE)) + if (row.bytes > bytesLimit) + failures.push( + `output is ${row.bytes.toLocaleString()} bytes, over the limit ${bytesLimit.toLocaleString()} (baseline ${expected.bytes.toLocaleString()} + 1%)`, + ) + const cssLimit = + expected.cssBytes + + Math.max(Math.floor(expected.cssBytes * CSS_TOLERANCE), CSS_MIN_SLACK) + if (row.cssBytes > cssLimit) + failures.push( + `CSS is ${row.cssBytes.toLocaleString()} bytes, over the limit ${cssLimit.toLocaleString()} (baseline ${expected.cssBytes.toLocaleString()})`, + ) + } + results.push({ target, failures }) + } + return { results, failed: results.some((entry) => entry.failures.length > 0) } +} + +/** The baseline for this run: the stored calibration runs plus this one, and what they say */ +export function nextBaseline(run, manifest, previous) { + const gated = manifest.targets.filter((target) => target.gate) + const rows = {} + for (const { name, group } of gated) { + const control = manifest.controls[group] + const calibration = [ + ...(previous?.rows[name]?.calibration ?? []), + timeRatio(run, name, control), + ].slice(-KEPT_CALIBRATION_RUNS) + rows[name] = { + control, + ratio: median(calibration), + calibration, + timeTolerance: timeTolerance(calibration), + bytes: run.rows[name].bytes, + cssBytes: run.rows[name].cssBytes, + } + } + return { samples: run.samples, rows } +} + +/** Replace only named, approved CSS baselines; default to per-file, never recalibrate. */ +export function nextCssBaseline(run, previous, targets = ['devup-ui']) { + if ( + targets.length === 0 || + targets.some( + (target) => + !['devup-ui', 'devup-ui-single', 'vinext-devup-ui'].includes(target), + ) + ) + throw new TypeError('CSS-only baseline requires approved named targets') + const rows = { ...previous.rows } + for (const target of targets) { + const expected = previous.rows[target] + if (!expected) + throw new TypeError( + `CSS-only baseline requires an existing ${target} row`, + ) + rows[target] = { ...expected, cssBytes: run.rows[target].cssBytes } + } + return { ...previous, rows } +} + +/** One line per gated row for the job log */ +export function report(run, baseline, evaluation) { + return evaluation.results + .map(({ target, failures }) => { + const expected = baseline.rows[target] + const row = run.rows[target] + const status = failures.length ? 'FAIL' : 'ok ' + if (!expected || !row) + return `${status} ${target}: ${failures.join('; ')}` + const ratio = timeRatio(run, target, expected.control) + return `${status} ${target}: ${row.median.toFixed(2)}s (${ratio.toFixed(3)}x ${expected.control}, baseline ${expected.ratio.toFixed(3)}x ±${(expected.timeTolerance * 100).toFixed(0)}%), ${row.bytes.toLocaleString()} bytes, css ${row.cssBytes.toLocaleString()} bytes${failures.length ? '\n ' + failures.join('\n ') : ''}` + }) + .join('\n') +} diff --git a/benchmark-manifest.json b/benchmark-manifest.json new file mode 100644 index 000000000..945f84e53 --- /dev/null +++ b/benchmark-manifest.json @@ -0,0 +1,28 @@ +{ + "samples": 3, + "targets": [ + { "name": "tailwind", "group": "webpack" }, + { "name": "stylex", "group": "webpack" }, + { "name": "stylex-turbo", "group": "turbopack" }, + { "name": "stylex-turbo-devup-ui", "group": "turbopack", "gate": true }, + { "name": "vanilla-extract", "group": "webpack" }, + { "name": "kuma-ui", "group": "webpack" }, + { "name": "panda-css", "group": "webpack" }, + { "name": "chakra-ui", "group": "webpack" }, + { "name": "mui", "group": "webpack" }, + { "name": "devup-ui", "group": "webpack", "gate": true }, + { "name": "devup-ui-single", "group": "webpack", "gate": true }, + { "name": "tailwind-turbo", "group": "turbopack" }, + { "name": "devup-ui-single-turbo", "group": "turbopack", "gate": true }, + { "name": "devup-ui-turbo", "group": "turbopack", "gate": true }, + { "name": "vanilla-extract-devup-ui", "group": "turbopack", "gate": true }, + { "name": "tailwind-turbo-devup-ui", "group": "turbopack", "gate": true }, + { "name": "vinext-devup-ui", "group": "vite", "gate": true }, + { "name": "devup-ui-collapse", "group": "turbopack", "gate": true } + ], + "controls": { + "webpack": "tailwind", + "turbopack": "tailwind-turbo", + "vite": "tailwind" + } +} diff --git a/benchmark-output.js b/benchmark-output.js new file mode 100644 index 000000000..efc9c28f2 --- /dev/null +++ b/benchmark-output.js @@ -0,0 +1,30 @@ +// Measure the fixture layouts actually emitted by Next.js and vinext. +import { existsSync, readdirSync, statSync } from 'node:fs' +import { join } from 'node:path' + +/** Sum all files, or only CSS files, recursively. Missing directories throw. */ +function directoryBytes(directory, cssOnly = false) { + let bytes = 0 + for (const entry of readdirSync(directory)) { + const path = join(directory, entry) + const stat = statSync(path) + if (stat.isDirectory()) bytes += directoryBytes(path, cssOnly) + else if (!cssOnly || path.endsWith('.css')) bytes += stat.size + } + return bytes +} + +/** Whole-build bytes include caches/server output; CSS bytes are shipped assets only. */ +export function measureOutput(benchmarkDir) { + // vinext also leaves a vestigial .next stub: keep preferring dist. + const distDir = join(benchmarkDir, 'dist') + const hasDist = existsSync(distDir) + const outputDir = hasDist ? distDir : join(benchmarkDir, '.next') + const browserDir = join(outputDir, hasDist ? 'client' : 'static') + return { + bytes: directoryBytes(outputDir), + // Next Webpack uses static/css; Turbopack uses static/chunks. + // An existing empty browser directory counts zero; a missing one throws. + cssBytes: directoryBytes(browserDir, true), + } +} diff --git a/benchmark-results.json b/benchmark-results.json index 5ef26dd7b..6546d9ae5 100644 --- a/benchmark-results.json +++ b/benchmark-results.json @@ -1,105 +1,161 @@ { "run": { - "id": "36702867475", - "url": "https://github.com/dev-five-git/devup-ui/actions/runs/36702867475", - "commit": "8d6c2f62", + "id": "37758611477", + "url": "https://github.com/dev-five-git/devup-ui/actions/runs/37758611477", + "commit": "467a379a", "runner": "ubuntu-24.04", "next": "16.3.3", "devupUi": "1.0.44" }, - "webpack": [ + "samples": 3, + "rows": [ { + "table": "webpack", + "target": "tailwind", "label": "tailwindcss", "version": "4.3.3", - "seconds": 16.67, - "bytes": 67332642, + "samples": [13.19597508, 13.021540712999995, 12.911684594999999], + "median": 13.021540712999995, + "min": 12.911684594999999, + "max": 13.19597508, + "bytes": 67339250, "cssBytes": 5852 }, { + "table": "webpack", + "target": "stylex", "label": "styleX", "version": "0.19.0", - "seconds": 38.08, - "bytes": 96291287, + "samples": [29.49553623, 28.717898384, 29.01065776699997], + "median": 29.01065776699997, + "min": 28.717898384, + "max": 29.49553623, + "bytes": 96290533, "cssBytes": 425 }, { + "table": "webpack", + "target": "vanilla-extract", "label": "vanilla-extract", "version": "1.21.2", - "seconds": 16.09, - "bytes": 68565036, + "samples": [12.34335704699999, 12.346740351999994, 12.215919330999954], + "median": 12.34335704699999, + "min": 12.215919330999954, + "max": 12.346740351999994, + "bytes": 68565511, "cssBytes": 294 }, { + "table": "webpack", + "target": "kuma-ui", "label": "kuma-ui", "version": "1.6.4", - "seconds": 17.91, - "bytes": 75627783, + "samples": [13.566745991999996, 13.491478220999998, 13.550664844000014], + "median": 13.550664844000014, + "min": 13.491478220999998, + "max": 13.566745991999996, + "bytes": 75627429, "cssBytes": 340 }, { + "table": "webpack", + "target": "panda-css", "label": "panda-css", "version": "1.12.0", - "seconds": 18.16, - "bytes": 71849418, + "samples": [13.581986012000009, 13.292706686999997, 13.438533212000038], + "median": 13.438533212000038, + "min": 13.292706686999997, + "max": 13.581986012000009, + "bytes": 71849243, "cssBytes": 15615 }, { + "table": "webpack", + "target": "chakra-ui", "label": "chakra-ui", "version": "3.37.0", - "seconds": 27.22, - "bytes": 207426274, + "samples": [21.061893298000008, 19.949156122000016, 20.292663101000013], + "median": 20.292663101000013, + "min": 19.949156122000016, + "max": 21.061893298000008, + "bytes": 207425028, "cssBytes": 0 }, { + "table": "webpack", + "target": "mui", "label": "mui", "version": "9.4.0", - "seconds": 18.78, - "bytes": 101473260, + "samples": [14.264408890999999, 13.777297842, 14.11791713899991], + "median": 14.11791713899991, + "min": 13.777297842, + "max": 14.264408890999999, + "bytes": 101476098, "cssBytes": 0 }, { + "table": "webpack", + "target": "devup-ui", "label": "devup-ui (per-file CSS)", "version": "1.0.44", - "seconds": 14.27, - "bytes": 67424897, - "cssBytes": 562, - "devup": true + "devup": true, + "samples": [11.089230005999998, 10.872500918999984, 10.948686922999913], + "median": 10.948686922999913, + "min": 10.872500918999984, + "max": 11.089230005999998, + "bytes": 67427695, + "cssBytes": 562 }, { + "table": "webpack", + "target": "devup-ui-single", "label": "devup-ui (single CSS)", "version": "1.0.44", - "seconds": 14.17, - "bytes": 67426959, - "cssBytes": 790, - "devup": true - } - ], - "turbopack": [ + "devup": true, + "samples": [11.042549753, 11.396381741000019, 10.884128520000028], + "median": 11.042549753, + "min": 10.884128520000028, + "max": 11.396381741000019, + "bytes": 67436336, + "cssBytes": 790 + }, { + "table": "turbopack", + "target": "tailwind-turbo", "label": "tailwindcss", "version": "4.3.3", - "samples": [7.1, 6.99, 6.98, 6.9, 6.91, 6.97], - "median": 6.98, - "bytes": 38422141, + "samples": [5.6750599369999835, 5.72619077299998, 5.509298593999935], + "median": 5.6750599369999835, + "min": 5.509298593999935, + "max": 5.72619077299998, + "bytes": 38440013, "cssBytes": 6197 }, { + "table": "turbopack", + "target": "devup-ui-single-turbo", "label": "devup-ui (direct APIs, single CSS)", "version": "1.0.44", - "samples": [6.98, 6.83, 6.95, 6.91, 6.9, 6.94], - "median": 6.93, - "bytes": 36518952, - "cssBytes": 327, - "devup": true + "devup": true, + "samples": [5.765447661000013, 5.794053518000001, 5.874383794999972], + "median": 5.794053518000001, + "min": 5.765447661000013, + "max": 5.874383794999972, + "bytes": 36538765, + "cssBytes": 327 }, { + "table": "turbopack", + "target": "vanilla-extract-devup-ui", "label": "devup-ui (static `.css.ts`)", "version": "1.0.44", - "samples": [6.93, 6.95, 6.85, 6.97, 6.87, 6.91], - "median": 6.92, - "bytes": 36578506, - "cssBytes": 268, - "devup": true + "devup": true, + "samples": [5.713730899999995, 5.988215032999986, 5.674502772999985], + "median": 5.713730899999995, + "min": 5.674502772999985, + "max": 5.988215032999986, + "bytes": 36580207, + "cssBytes": 268 } ] } diff --git a/benchmark.js b/benchmark.js index 3c6c4b2cb..27c2228b8 100644 --- a/benchmark.js +++ b/benchmark.js @@ -1,14 +1,24 @@ -import { - existsSync, - readdirSync, - rmSync, - statSync, - writeFileSync, -} from 'node:fs' +// Cold-build benchmark of every fixture in benchmark-manifest.json. +// +// Sampling policy: every target gets the same number of cold samples +// (`samples` in the manifest). Samples run in rounds, and each round starts the +// list at a different place, so no target always runs first (when the runner is +// coolest) or last. A row is reported as the median of its samples with its +// spread (min and max); the median is what gets compared and published. +// +// The machine-readable run goes to benchmark-run.json: `benchmark-check.js` +// compares it with the checked baseline, and the README tables are written from +// the checked results (benchmark-results.json, render-benchmark-readme.js). +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { execSync } from 'child_process' +import { median, roundOrder } from './benchmark-gate.js' +import { measureOutput } from './benchmark-output.js' + +const manifest = JSON.parse(readFileSync('benchmark-manifest.json', 'utf8')) + function clearBuildFile(dir) { const base = join('./benchmark', dir) for (const output of ['.next', 'dist', 'df', 'tsconfig.tsbuildinfo']) { @@ -17,32 +27,6 @@ function clearBuildFile(dir) { } } -function checkDirSize(path, filter) { - let totalSize = 0 - - function calculateSize(directory) { - const entries = readdirSync(directory) - for (const entry of entries) { - const entryPath = join(directory, entry) - if (statSync(entryPath).isDirectory()) { - calculateSize(entryPath) // 재귀적으로 하위 폴더 크기 계산 - } else if (!filter || filter(entryPath)) { - const stats = statSync(entryPath) - totalSize += stats.size // 파일 크기 합산 - } - } - } - - calculateSize(path) - return totalSize -} - -// Sum only the size of emitted CSS files. Build-size totals are dominated by -// JS/assets and hide CSS-only differences (e.g. single-importer collapse). -function checkCssSize(path) { - return checkDirSize(path, (p) => p.endsWith('.css')) -} - let benchmarkRun = 0 function benchmark(target) { @@ -62,102 +46,53 @@ function benchmark(target) { performance.measure(run, run + '-start', run + '-end') const benchmarkDir = join('./benchmark', dir) - // Resolve the real build-output dir. Next.js emits to `.next`; Vite emits to - // `dist`. vinext (Next-on-Vite) emits its real artifacts to `dist` but ALSO - // leaves a tiny vestigial `.next` stub (~988 B, no CSS) - so checking `.next` - // first measured the empty stub and reported "988 bytes (css 0 bytes)" even - // though dist held ~1.28 MB incl. the extracted CSS. Prefer `dist` when it - // exists; fall back to `.next` for pure Next.js apps (which never emit dist). - const distDir = join(benchmarkDir, 'dist') - const outputDir = existsSync(distDir) ? distDir : join(benchmarkDir, '.next') - const duration = performance.getEntriesByName(run)[0].duration / 1000 - const bytes = checkDirSize(outputDir) - const cssBytes = checkCssSize(outputDir) return { - duration, - bytes, - cssBytes, - result: `${target} ${duration.toFixed(2)}s ${bytes.toLocaleString()} bytes (css ${cssBytes.toLocaleString()} bytes)`, + seconds: performance.getEntriesByName(run)[0].duration / 1000, + ...measureOutput(benchmarkDir), } } -let result = [] -const turboSamples = new Map([ - ['tailwind-turbo', []], - ['devup-ui-single-turbo', []], - ['vanilla-extract-devup-ui', []], -]) - -const measured = [] - -function record(target) { - const sample = benchmark(target) - result.push(sample.result) - measured.push({ - target, - seconds: sample.duration, - bytes: sample.bytes, - cssBytes: sample.cssBytes, - }) -} - -record('tailwind') -record('stylex') -record('stylex-turbo') -record('stylex-turbo-devup-ui') -record('vanilla-extract') -record('kuma-ui') -record('panda-css') -record('chakra-ui') -record('mui') -record('devup-ui') -record('devup-ui-single') -record('tailwind-turbo') -record('devup-ui-single-turbo') -record('devup-ui-turbo') -record('vanilla-extract-devup-ui') -record('tailwind-turbo-devup-ui') -record('vinext-devup-ui') -// Multi-component app exercising single-importer collapse (atom dedup). -record('devup-ui-collapse') +const names = manifest.targets.map((target) => target.name) +const samples = new Map( + names.map((name) => [name, { seconds: [], bytes: [], cssBytes: [] }]), +) -// A single fixed-order result on a shared CI runner is too noisy for direct -// comparisons. Run six cold samples in a repeated Latin-square order so each -// target occupies each position twice, then report their medians. The vanilla -// target contains a static `.css.ts` module; the single target uses Devup APIs. -const turboTargets = [ - 'tailwind-turbo', - 'devup-ui-single-turbo', - 'vanilla-extract-devup-ui', -] -for (let sample = 0; sample < 6; sample++) { - const offset = sample % turboTargets.length - const order = [ - ...turboTargets.slice(offset), - ...turboTargets.slice(0, offset), - ] - for (const target of order) { - turboSamples.get(target).push(benchmark(target).duration) +for (let round = 0; round < manifest.samples; round++) { + for (const name of roundOrder(names, round, manifest.samples)) { + const sample = benchmark(name) + const row = samples.get(name) + row.seconds.push(sample.seconds) + row.bytes.push(sample.bytes) + row.cssBytes.push(sample.cssBytes) } } -function median(samples) { - const sorted = samples.toSorted((a, b) => a - b) - const middle = sorted.length / 2 - return (sorted[middle - 1] + sorted[middle]) / 2 -} - -for (const target of turboTargets) { - const samples = turboSamples.get(target) - result.push( - `${target} median ${median(samples).toFixed(2)}s (${samples.length} cold samples: ${samples.map((sample) => sample.toFixed(2) + 's').join(', ')})`, - ) -} +const rows = Object.fromEntries( + names.map((name) => { + const { seconds, bytes, cssBytes } = samples.get(name) + return [ + name, + { + samples: seconds, + median: median(seconds), + min: Math.min(...seconds), + max: Math.max(...seconds), + bytes: median(bytes), + cssBytes: median(cssBytes), + }, + ] + }), +) -console.info(result.join('\n')) +console.info( + names + .map((name) => { + const row = rows[name] + return `${name} median ${row.median.toFixed(2)}s (${row.samples.length} cold samples: ${row.samples.map((sample) => sample.toFixed(2) + 's').join(', ')}) ${row.bytes.toLocaleString()} bytes (css ${row.cssBytes.toLocaleString()} bytes)` + }) + .join('\n'), +) -// The machine-readable run, the CI artifact the README tables are written from -// (see benchmark-results.json and render-benchmark-readme.js) writeFileSync( 'benchmark-run.json', JSON.stringify( @@ -166,16 +101,8 @@ writeFileSync( id: process.env.GITHUB_RUN_ID ?? null, commit: process.env.GITHUB_SHA ?? null, }, - measured, - turbopack: Object.fromEntries( - turboTargets.map((target) => [ - target, - { - samples: turboSamples.get(target), - median: median(turboSamples.get(target)), - }, - ]), - ), + samples: manifest.samples, + rows, }, null, 2, diff --git a/bindings/devup-ui-wasm/size-budget.json b/bindings/devup-ui-wasm/size-budget.json new file mode 100644 index 000000000..0bb43978c --- /dev/null +++ b/bindings/devup-ui-wasm/size-budget.json @@ -0,0 +1,22 @@ +{ + "wasm": { + "raw": { + "growth": 0.01, + "roundBytes": 1024 + }, + "gzip": { + "growth": 0.01, + "roundBytes": 1024 + } + }, + "glue": { + "raw": { + "growth": 0.01, + "roundBytes": 1024 + }, + "gzip": { + "growth": 0.01, + "roundBytes": 1024 + } + } +} diff --git a/libs/extractor/Cargo.toml b/libs/extractor/Cargo.toml index 2b1e59744..42e0ba10e 100644 --- a/libs/extractor/Cargo.toml +++ b/libs/extractor/Cargo.toml @@ -44,3 +44,7 @@ harness = false [[bench]] name = "alloc_benchmark" harness = false + +[[bench]] +name = "semantic_pass_benchmark" +harness = false diff --git a/libs/extractor/benches/semantic_pass_benchmark.rs b/libs/extractor/benches/semantic_pass_benchmark.rs new file mode 100644 index 000000000..41c7f8964 --- /dev/null +++ b/libs/extractor/benches/semantic_pass_benchmark.rs @@ -0,0 +1,111 @@ +#![allow(clippy::expect_used, clippy::unwrap_used)] + +//! What the `SemanticBuilder` passes cost the extractor. +//! +//! A file that imports `css` or `keyframes` pays one scope-analysis pass before +//! extraction (to read the style results it binds); a file with an error pays a +//! second one to report reads of what the build compiled away. Each group below +//! pairs two inputs that differ only in that pass, and `semantic_floor` is the +//! pass on its own, so the difference between the pair can be read against it. +//! Run: `cargo bench -p extractor --bench semantic_pass_benchmark`. + +use criterion::{Criterion, criterion_group, criterion_main}; +use std::collections::HashMap; +use std::hint::black_box; + +use css::class_map::reset_class_map; +use css::debug::set_debug; +use css::file_map::reset_file_map; +use css::set_prefix; +use extractor::{ExtractOption, extract}; +use oxc_allocator::Allocator; +use oxc_parser::Parser; +use oxc_semantic::SemanticBuilder; +use oxc_span::SourceType; + +fn make_option() -> ExtractOption { + ExtractOption { + package: "@devup-ui/react".to_string(), + css_dir: "@devup-ui/react".to_string(), + single_css: true, + import_main_css: false, + import_aliases: HashMap::new(), + } +} + +fn reset_state() { + reset_class_map(); + reset_file_map(); + set_debug(false); + set_prefix(None); +} + +const BODY: &str = r#" +export const a = + + + + + +"#; + +/// The same file with and without the import that turns the pass on +fn without_pass() -> String { + format!("import {{Box, Flex, Text}} from '@devup-ui/react'{BODY}") +} + +fn with_pass() -> String { + format!("import {{Box, Flex, Text, css}} from '@devup-ui/react'{BODY}") +} + +/// A file that reads what the build compiled away, so the error pass runs, and +/// the file that does not read it +fn with_error_pass() -> String { + format!( + "import {{Box, Flex, Text, css}} from '@devup-ui/react'\nexport const runtime = css{BODY}" + ) +} + +fn bench_extract(c: &mut Criterion, group: &str, name: &str, input: &str) { + c.benchmark_group(group).bench_function(name, |b| { + b.iter(|| { + reset_state(); + let _ = extract(black_box("test.tsx"), black_box(input), make_option()); + }); + }); +} + +/// The floor: parsing alone, and parsing plus the scope analysis the passes run +fn bench_floor(c: &mut Criterion, input: &str) { + let mut floor = c.benchmark_group("semantic_floor"); + floor.bench_function("parse", |b| { + b.iter(|| { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, black_box(input), SourceType::tsx()).parse(); + black_box(parsed.program.body.len()) + }); + }); + floor.bench_function("parse_and_semantic", |b| { + b.iter(|| { + let allocator = Allocator::default(); + let parsed = Parser::new(&allocator, black_box(input), SourceType::tsx()).parse(); + let semantic = SemanticBuilder::new().build(&parsed.program).semantic; + black_box(semantic.scoping().symbols_len()) + }); + }); + floor.finish(); +} + +fn criterion_benchmark(c: &mut Criterion) { + let (off, on, error) = (without_pass(), with_pass(), with_error_pass()); + + bench_floor(c, &on); + + bench_extract(c, "style_values_pass", "off", &off); + bench_extract(c, "style_values_pass", "on", &on); + bench_extract(c, "report_compiled_reads_pass", "off", &on); + bench_extract(c, "report_compiled_reads_pass", "on", &error); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/packages/plugin-utils/src/__tests__/benchmark-css-baseline.test.ts b/packages/plugin-utils/src/__tests__/benchmark-css-baseline.test.ts new file mode 100644 index 000000000..a87ba6c4a --- /dev/null +++ b/packages/plugin-utils/src/__tests__/benchmark-css-baseline.test.ts @@ -0,0 +1,233 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { describe, expect, it } from 'bun:test' + +import storedBaseline from '../../../../benchmark-baseline.json' +import { nextCssBaseline } from '../../../../benchmark-gate.js' + +const baseline = { + ...storedBaseline, + revision: 'baseline-only', + rows: { + ...storedBaseline.rows, + 'devup-ui': { ...storedBaseline.rows['devup-ui'], cssBytes: 562 }, + 'devup-ui-single': { + ...storedBaseline.rows['devup-ui-single'], + cssBytes: 790, + }, + 'vinext-devup-ui': { + ...storedBaseline.rows['vinext-devup-ui'], + cssBytes: 29746, + }, + }, +} + +// Deliberately disagree with every stored field, including the history. +const run = { + revision: 'run-only', + samples: 9, + rows: Object.fromEntries( + Object.entries(baseline.rows).map(([target, row]) => [ + target, + { + control: 'different-control', + ratio: row.ratio + 1, + calibration: [9], + timeTolerance: row.timeTolerance + 1, + bytes: row.bytes + 123, + cssBytes: target === 'devup-ui' ? 494 : row.cssBytes + 456, + samples: [99], + median: 99, + min: 98, + max: 100, + }, + ]), + ), +} +const expected = { + ...baseline, + rows: { + ...baseline.rows, + 'devup-ui': { ...baseline.rows['devup-ui'], cssBytes: 494 }, + }, +} +const pairBaseline = expected +const pairRun = { + ...run, + rows: { + ...run.rows, + 'devup-ui': { ...run.rows['devup-ui'], cssBytes: 617 }, + 'devup-ui-single': { ...run.rows['devup-ui-single'], cssBytes: 395 }, + 'vinext-devup-ui': { ...run.rows['vinext-devup-ui'], cssBytes: 14873 }, + }, +} +const pairExpected = { + ...pairBaseline, + rows: { + ...pairBaseline.rows, + 'devup-ui-single': { + ...pairBaseline.rows['devup-ui-single'], + cssBytes: 395, + }, + 'vinext-devup-ui': { + ...pairBaseline.rows['vinext-devup-ui'], + cssBytes: 14873, + }, + }, +} + +describe('CSS-only benchmark baseline', () => { + it('changes only the two named CSS fields when every run field disagrees', () => { + const previous = structuredClone(pairBaseline) + const input = structuredClone(pairRun) + + const result = nextCssBaseline(input, previous, [ + 'devup-ui-single', + 'vinext-devup-ui', + ]) + + expect(result).toEqual(pairExpected) + expect(previous).toEqual(pairBaseline) + expect(input).toEqual(pairRun) + }) + + it.each([ + { targets: [] }, + { targets: ['devup-ui-turbo'] }, + { targets: ['devup-ui-single', ''] }, + { targets: ['unknown'] }, + ])( + 'rejects unsupported or missing named targets when given $targets', + ({ targets }) => { + expect(() => nextCssBaseline(pairRun, pairBaseline, targets)).toThrow( + TypeError, + ) + }, + ) + + it.each(['devup-ui-single', 'vinext-devup-ui'])( + 'rejects missing selected rows when updating %s', + (target) => { + expect(() => nextCssBaseline(pairRun, { rows: {} }, [target])).toThrow( + TypeError, + ) + }, + ) + + it.each(['devup-ui-single', 'vinext-devup-ui'])( + 'rejects missing selected measurements when updating %s', + (target) => { + expect(() => + nextCssBaseline({ rows: {} }, pairBaseline, [target]), + ).toThrow(TypeError) + }, + ) + + it('preserves every other field when the run disagrees with the baseline', () => { + const previous = structuredClone(baseline) + const input = structuredClone(run) + + const result = nextCssBaseline(input, previous) + + expect(result).toEqual(expected) + expect(previous).toEqual(baseline) + expect(input).toEqual(run) + }) + + it('preserves the baseline when CSS bytes are unchanged', () => { + const input = { + ...run, + rows: { + ...run.rows, + 'devup-ui': { cssBytes: baseline.rows['devup-ui'].cssBytes }, + }, + } + + const result = nextCssBaseline(input, baseline) + + expect(result).toEqual(baseline) + }) + + it('rejects a baseline when the existing per-file row is absent', () => { + const previous = { samples: 3, rows: {} } + + expect(() => nextCssBaseline(run, previous)).toThrow(TypeError) + }) + + it('rejects a run when the per-file measurement is absent', () => { + const input = { samples: 3, rows: {} } + + expect(() => nextCssBaseline(input, baseline)).toThrow(TypeError) + }) +}) + +describe('CSS-only baseline CLI', () => { + it.each([ + { args: ['--write-css-baseline', 'devup-ui'], success: true }, + { + args: ['--write-css-baseline', 'devup-ui-single,vinext-devup-ui'], + success: true, + }, + { args: ['--write-css-baseline', 'devup-ui-turbo'], success: false }, + { + args: ['--write-css-baseline', 'devup-ui-single,devup-ui-turbo'], + success: false, + }, + { args: ['--write-css-baseline', ''], success: false }, + { args: ['--write-css-baseline', 'devup-ui-single,'], success: false }, + { + args: ['--write-css-baseline', 'devup-ui-single', 'vinext-devup-ui'], + success: false, + }, + { args: ['--write-css-baseline'], success: false }, + { + args: ['--write-css-baseline', 'devup-ui', '--write-baseline'], + success: false, + }, + { + args: ['--write-css-baseline', 'devup-ui', '--write-results'], + success: false, + }, + ])( + 'writes only explicitly named CSS when invoked with $args', + ({ args, success }) => { + const directory = mkdtempSync(join(tmpdir(), 'devup-css-baseline-')) + const paired = args[1] === 'devup-ui-single,vinext-devup-ui' + const stored = + JSON.stringify(paired ? pairBaseline : baseline, null, 2) + '\n' + try { + writeFileSync(join(directory, 'benchmark-baseline.json'), stored) + writeFileSync(join(directory, 'benchmark-manifest.json'), '{}\n') + writeFileSync( + join(directory, 'benchmark-run.json'), + JSON.stringify(paired ? pairRun : run), + ) + writeFileSync(join(directory, 'benchmark-results.json'), '{}\n') + + const result = spawnSync( + process.execPath, + [join(import.meta.dir, '../../../../benchmark-check.js'), ...args], + { cwd: directory, encoding: 'utf8', timeout: 10_000 }, + ) + + expect(result.error).toBeUndefined() + expect(result.status).toBe(success ? 0 : 1) + expect( + readFileSync(join(directory, 'benchmark-baseline.json'), 'utf8'), + ).toBe( + success + ? JSON.stringify(paired ? pairExpected : expected, null, 2) + '\n' + : stored, + ) + expect( + readFileSync(join(directory, 'benchmark-results.json'), 'utf8'), + ).toBe('{}\n') + } finally { + rmSync(directory, { recursive: true, force: true }) + } + }, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/benchmark-gate.test.ts b/packages/plugin-utils/src/__tests__/benchmark-gate.test.ts new file mode 100644 index 000000000..83fa669ce --- /dev/null +++ b/packages/plugin-utils/src/__tests__/benchmark-gate.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from 'bun:test' + +import { + BYTES_TOLERANCE, + CSS_MIN_SLACK, + evaluate, + KEPT_CALIBRATION_RUNS, + median, + MIN_TIME_TOLERANCE, + nextBaseline, + relativeSpread, + report, + roundOrder, + timeRatio, + timeTolerance, +} from '../../../../benchmark-gate.js' + +interface Row { + samples: number[] + median: number + min: number + max: number + bytes: number + cssBytes: number +} +interface Run { + samples: number + rows: Record +} + +const manifest = { + samples: 3, + targets: [ + { name: 'tailwind', group: 'webpack' }, + { name: 'tailwind-turbo', group: 'turbopack' }, + { name: 'devup-ui', group: 'webpack', gate: true }, + { name: 'devup-ui-turbo', group: 'turbopack', gate: true }, + { name: 'mui', group: 'webpack' }, + ], + controls: { webpack: 'tailwind', turbopack: 'tailwind-turbo' }, +} + +function row(seconds: number, bytes: number, cssBytes: number): Row { + return { + samples: [seconds, seconds, seconds], + median: seconds, + min: seconds, + max: seconds, + bytes, + cssBytes, + } +} + +/** A run of the normal speed: Tailwind takes `control`, Devup UI 0.86 of it */ +function normalRun(control = 16): Run { + return { + samples: 3, + rows: { + tailwind: row(control, 67_000_000, 5_000), + 'tailwind-turbo': row(control / 2, 38_000_000, 6_000), + 'devup-ui': row(control * 0.86, 67_400_000, 562), + 'devup-ui-turbo': row(control / 2, 36_500_000, 327), + mui: row(control * 1.2, 101_000_000, 0), + }, + } +} + +const calibration = (ratios: Record) => + Object.fromEntries( + Object.entries(ratios).map(([name, values]) => [name, values]), + ) + +function baselineOf(run: Run) { + const first = nextBaseline(run, manifest, undefined) + // The ratios of five earlier runs, a few percent apart + const history = calibration({ + 'devup-ui': [0.85, 0.87, 0.9, 0.86, 0.86], + 'devup-ui-turbo': [0.97, 1.0, 0.98, 1.0, 1.0], + }) + for (const [name, values] of Object.entries(history)) { + first.rows[name].calibration = values + first.rows[name].ratio = median(values) + first.rows[name].timeTolerance = timeTolerance(values) + } + return first +} + +describe('sampling helpers', () => { + it('takes the median and the spread of samples', () => { + expect(median([3, 1, 2])).toBe(2) + expect(median([4, 1, 3, 2])).toBe(2.5) + expect(relativeSpread([9, 10, 11])).toBe(0.2) + }) + + it('starts each round at another place of the list, so every target takes other positions', () => { + const names = ['a', 'b', 'c', 'd', 'e', 'f'] + expect(roundOrder(names, 0, 3)).toEqual(['a', 'b', 'c', 'd', 'e', 'f']) + expect(roundOrder(names, 1, 3)).toEqual(['c', 'd', 'e', 'f', 'a', 'b']) + expect(roundOrder(names, 2, 3)).toEqual(['e', 'f', 'a', 'b', 'c', 'd']) + const firsts = [0, 1, 2].map((round) => roundOrder(names, round, 3)[0]) + expect(new Set(firsts).size).toBe(3) + }) + + it('allows at least the minimum tolerance and twice the observed spread', () => { + expect(timeTolerance([1, 1.01, 1])).toBe(MIN_TIME_TOLERANCE) + expect(timeTolerance([1, 1.2, 1])).toBeCloseTo(0.4, 5) + }) + + it('reads a row as a ratio to its control of the same run', () => { + expect(timeRatio(normalRun(), 'devup-ui', 'tailwind')).toBeCloseTo(0.86, 5) + }) +}) + +describe('benchmark gate', () => { + const baseline = baselineOf(normalRun()) + + it('passes a normal run', () => { + const evaluation = evaluate(normalRun(), baseline) + + expect(evaluation.failed).toBe(false) + expect(evaluation.results.map((entry) => entry.target)).toEqual([ + 'devup-ui', + 'devup-ui-turbo', + ]) + }) + + it('passes a run on a slower or faster machine, as every row moves with its control', () => { + expect(evaluate(normalRun(24), baseline).failed).toBe(false) + expect(evaluate(normalRun(11), baseline).failed).toBe(false) + }) + + it('passes ordinary noise between runs', () => { + const run = normalRun() + run.rows['devup-ui'] = row(16 * 0.9, 67_400_000 + 30_000, 562) + run.rows['devup-ui-turbo'] = row(8.2, 36_500_000, 327) + + expect(evaluate(run, baseline).failed).toBe(false) + }) + + it('fails a build that got slower than its control by more than the noise', () => { + const run = normalRun() + // An artificial delay of 3 s on a 13.8 s build + run.rows['devup-ui'] = row(16 * 0.86 + 3, 67_400_000, 562) + + const evaluation = evaluate(run, baseline) + + expect(evaluation.failed).toBe(true) + expect(evaluation.results[0].failures[0]).toMatch( + /build time is 1\.0\d\dx tailwind, over the limit/, + ) + expect(evaluation.results[1].failures).toEqual([]) + }) + + it('does not fail a slower control row, or any row that is not gated', () => { + const run = normalRun() + run.rows.mui = row(60, 500_000_000, 99_999) + + expect(evaluate(run, baseline).failed).toBe(false) + }) + + it('fails an output that grew beyond one percent', () => { + const run = normalRun() + run.rows['devup-ui-turbo'] = row( + 8, + Math.ceil(36_500_000 * (1 + BYTES_TOLERANCE)) + 1, + 327, + ) + + const evaluation = evaluate(run, baseline) + + expect(evaluation.failed).toBe(true) + expect(evaluation.results[1].failures[0]).toMatch( + /output is .* over the limit/, + ) + }) + + it('fails CSS that grew beyond five percent and the slack', () => { + const run = normalRun() + run.rows['devup-ui'] = row(16 * 0.86, 67_400_000, 562 + 28 + CSS_MIN_SLACK) + expect(evaluate(run, baseline).failed).toBe(true) + + run.rows['devup-ui'] = row(16 * 0.86, 67_400_000, 562 + CSS_MIN_SLACK) + expect(evaluate(run, baseline).failed).toBe(false) + }) + + it('fails a gated row missing from the run, and a run with another sampling policy', () => { + const run = normalRun() + delete run.rows['devup-ui'] + expect(evaluate(run, baseline).results[0].failures).toEqual([ + 'missing from the run', + ]) + + const lone = { ...normalRun(), samples: 1 } + const evaluation = evaluate(lone, baseline) + expect(evaluation.failed).toBe(true) + expect(evaluation.results[0].target).toBe('(policy)') + }) + + it('fails a row whose control is missing from the run', () => { + const run = normalRun() + delete run.rows.tailwind + + expect(evaluate(run, baseline).failed).toBe(true) + }) +}) + +describe('benchmark baseline', () => { + it('records the gated rows with their control, ratio, sizes and calibration', () => { + const baseline = nextBaseline(normalRun(), manifest, undefined) + + expect(Object.keys(baseline.rows)).toEqual(['devup-ui', 'devup-ui-turbo']) + expect(baseline.samples).toBe(3) + expect(baseline.rows['devup-ui']).toMatchObject({ + control: 'tailwind', + bytes: 67_400_000, + cssBytes: 562, + timeTolerance: MIN_TIME_TOLERANCE, + }) + expect(baseline.rows['devup-ui'].ratio).toBeCloseTo(0.86, 5) + expect(baseline.rows['devup-ui-turbo'].control).toBe('tailwind-turbo') + }) + + it('adds a run to the calibration, keeps the latest ones and widens the tolerance by the spread', () => { + let baseline = nextBaseline(normalRun(), manifest, undefined) + for (let index = 0; index < KEPT_CALIBRATION_RUNS + 2; index++) + baseline = nextBaseline(normalRun(16 + (index % 2)), manifest, baseline) + expect(baseline.rows['devup-ui'].calibration).toHaveLength( + KEPT_CALIBRATION_RUNS, + ) + + const noisy = normalRun() + noisy.rows['devup-ui'] = row(16 * 0.86 * 1.4, 67_400_000, 562) + const widened = nextBaseline(noisy, manifest, baseline) + expect(widened.rows['devup-ui'].timeTolerance).toBeGreaterThan( + MIN_TIME_TOLERANCE, + ) + }) +}) + +describe('benchmark report', () => { + it('prints a line per gated row and what failed', () => { + const baseline = baselineOf(normalRun()) + const run = normalRun() + run.rows['devup-ui'] = row(16 * 0.86 + 3, 67_400_000, 562) + const text = report(run, baseline, evaluate(run, baseline)) + + expect(text).toMatch(/^FAIL devup-ui: 16\.76s/) + expect(text).toContain('over the limit') + expect(text).toMatch(/ok {3}devup-ui-turbo: 8\.00s/) + }) + + it('prints a failure without a row to describe', () => { + const baseline = baselineOf(normalRun()) + const run = normalRun() + delete run.rows['devup-ui'] + const text = report(run, baseline, evaluate(run, baseline)) + + expect(text).toContain('FAIL devup-ui: missing from the run') + + const lone = { ...normalRun(), samples: 1 } + expect(report(lone, baseline, evaluate(lone, baseline))).toContain( + 'FAIL (policy): the run took 1 samples per row', + ) + }) +}) diff --git a/packages/plugin-utils/src/__tests__/benchmark-output.test.ts b/packages/plugin-utils/src/__tests__/benchmark-output.test.ts new file mode 100644 index 000000000..d6726e9e0 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/benchmark-output.test.ts @@ -0,0 +1,197 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import { evaluate } from '../../../../benchmark-gate.js' +import { measureOutput } from '../../../../benchmark-output.js' + +describe('benchmark output measurement', () => { + let directory: string + + beforeEach(() => { + directory = mkdtempSync(join(tmpdir(), 'devup-benchmark-output-')) + }) + + afterEach(() => { + rmSync(directory, { recursive: true, force: true }) + }) + + function file(path: string, bytes: number) { + const destination = join(directory, path) + mkdirSync(dirname(destination), { recursive: true }) + writeFileSync(destination, Buffer.alloc(bytes, 'x')) + } + + it.each(['static/css/page.css', 'static/chunks/nested/page.css'])( + 'counts only shipped CSS when Next emits %s alongside compiler caches', + (path) => { + file('.next/' + path, 494) + file('.next/cache/devup-ui_build/page.css', 450) + file('.next/cache/devup-ui_build/base.css', 68) + file('.next/server/app/page.css', 83) + file('.next/static/chunks/app.js', 101) + + const result = measureOutput(directory) + + expect(result).toEqual({ bytes: 1196, cssBytes: 494 }) + }, + ) + + it('prefers dist and counts client CSS when vinext also emits a Next stub', () => { + file('.next/static/css/stub.css', 999) + file('dist/client/assets/page.css', 31) + file('dist/client/assets/nested/shared.css', 17) + file('dist/client/assets/app.js', 101) + file('dist/server/assets/page.css', 67) + file('dist/server/app.js', 83) + + const result = measureOutput(directory) + + expect(result).toEqual({ bytes: 299, cssBytes: 48 }) + }) + + it('rejects measurement when neither output directory exists', () => { + expect(() => measureOutput(directory)).toThrow() + }) + + it.each(['.next', 'dist'])( + 'rejects measurement when %s has no required browser-assets directory', + (output) => { + file(output + '/server/page.css', 123) + + expect(() => measureOutput(directory)).toThrow() + }, + ) + + it('does not fall back to Next when the preferred dist lacks client assets', () => { + file('.next/static/css/page.css', 494) + file('dist/server/page.css', 123) + + expect(() => measureOutput(directory)).toThrow() + }) + + it.each(['.next/static', 'dist/client'])( + 'counts zero CSS when the existing browser-assets directory %s is empty', + (browser) => { + mkdirSync(join(directory, browser), { recursive: true }) + + const result = measureOutput(directory) + + expect(result).toEqual({ bytes: 0, cssBytes: 0 }) + }, + ) + + it.each(['.next/static', 'dist/client'])( + 'counts zero CSS when %s contains only non-CSS assets', + (browser) => { + file(browser + '/assets/app.js', 41) + file(browser + '/assets/page.css.map', 23) + + const result = measureOutput(directory) + + expect(result).toEqual({ bytes: 64, cssBytes: 0 }) + }, + ) + + it.each([ + { cssBytes: 494, failed: false }, + { cssBytes: 526, failed: false }, + { cssBytes: 527, failed: true }, + ])( + 'enforces shipped CSS headroom when the asset is $cssBytes bytes', + ({ cssBytes, failed }) => { + file('.next/static/css/page.css', cssBytes) + file('.next/cache/devup-ui_build/page.css', 450) + const baseline = { + samples: 3, + rows: { + 'devup-ui': { + control: 'tailwind', + ratio: 1, + timeTolerance: 0.1, + bytes: 1000, + cssBytes: 494, + }, + }, + } + const run = { + samples: 3, + rows: { + tailwind: { median: 1 }, + 'devup-ui': { median: 1, ...measureOutput(directory) }, + }, + } + + const evaluation = evaluate(run, baseline) + + expect(evaluation.failed).toBe(failed) + expect(evaluation.results).toEqual([ + { + target: 'devup-ui', + failures: failed + ? ['CSS is 527 bytes, over the limit 526 (baseline 494)'] + : [], + }, + ]) + }, + ) +}) + +describe('approved CSS baseline headroom', () => { + it.each([ + { + target: 'devup-ui-single', + baselineCss: 395, + cssBytes: 427, + failed: false, + }, + { + target: 'devup-ui-single', + baselineCss: 395, + cssBytes: 428, + failed: true, + }, + { + target: 'vinext-devup-ui', + baselineCss: 14873, + cssBytes: 15616, + failed: false, + }, + { + target: 'vinext-devup-ui', + baselineCss: 14873, + cssBytes: 15617, + failed: true, + }, + ])( + 'enforces the unchanged CSS gate for $target at $cssBytes bytes', + ({ target, baselineCss, cssBytes, failed }) => { + const baseline = { + samples: 3, + rows: { + [target]: { + control: 'tailwind', + ratio: 1, + timeTolerance: 0.1, + bytes: 1000, + cssBytes: baselineCss, + }, + }, + } + const run = { + samples: 3, + rows: { + tailwind: { median: 1 }, + [target]: { median: 1, bytes: 1000, cssBytes }, + }, + } + + const evaluation = evaluate(run, baseline) + + expect(evaluation.failed).toBe(failed) + expect(evaluation.results[0]?.failures).toHaveLength(failed ? 1 : 0) + }, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/wasm-bindgen-version.test.ts b/packages/plugin-utils/src/__tests__/wasm-bindgen-version.test.ts new file mode 100644 index 000000000..c450b3da1 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/wasm-bindgen-version.test.ts @@ -0,0 +1,137 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { afterEach, describe, expect, it } from 'bun:test' + +import { resolveWasmBindgenVersion } from '../../../../wasm-bindgen-version.js' + +const resolver = fileURLToPath( + new URL('../../../../resolve-wasm-bindgen.js', import.meta.url), +) +const committedLock = fileURLToPath( + new URL('../../../../Cargo.lock', import.meta.url), +) +const committedVersion = readFileSync(committedLock, 'utf8').match( + /\[\[package\]\]\r?\nname = "wasm-bindgen"\r?\nversion = "([^"]+)"/, +)?.[1] +const roots: string[] = [] +const packageEntry = (version: string) => + `[[package]]\nname = "wasm-bindgen"\nversion = ${JSON.stringify(version)}\n` + +afterEach(() => { + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }) +}) + +describe('committed wasm-bindgen version resolution', () => { + it.each(['0.2.128', '0.2.129', '1.0.0-rc.1+build.42'])( + 'preserves the exact committed semver when it is %s', + (version) => { + const lock = `version = 4\n${packageEntry(version)}` + const result = resolveWasmBindgenVersion(lock) + expect(result).toBe(version) + }, + ) + + it('deduplicates equal versions when package sources differ', () => { + const lock = `${packageEntry('0.2.128')}source = "registry+fixture"\n${packageEntry('0.2.128')}source = "git+fixture"\n` + const result = resolveWasmBindgenVersion(lock) + expect(result).toBe('0.2.128') + }) + + it('ignores other packages and misleading comments when resolving', () => { + const lock = `# name = "wasm-bindgen"\n[[package]]\nname = "wasm-bindgen-cli"\nversion = "9.0.0"\n${packageEntry('0.2.128')}` + const result = resolveWasmBindgenVersion(lock) + expect(result).toBe('0.2.128') + }) + + it.each([ + { lock: '' }, + { lock: 'version = 4' }, + { lock: '[package]\nname = "wasm-bindgen"\nversion = "0.2.128"' }, + { lock: 'package = ["wasm-bindgen", 128]' }, + { lock: '[[package]]\nname = "other"\nversion = "0.2.128"' }, + { lock: '[[package]]\nname = "wasm-bindgen"' }, + { lock: '[[package]]\nname = "wasm-bindgen"\nversion = 128' }, + { lock: packageEntry('0.2.128') + packageEntry('0.2.129') }, + { lock: packageEntry('0.2.128') + packageEntry('invalid') }, + { lock: '[[package]\nname = "wasm-bindgen"' }, + { lock: '[[package]]\nname = "wasm-bindgen"\nversion = "unterminated' }, + ])('rejects invalid, missing or ambiguous lock packages %j', ({ lock }) => { + expect(() => resolveWasmBindgenVersion(lock)).toThrow() + }) + + it.each([ + '', + 'latest', + 'v0.2.128', + '^0.2.128', + '0.2', + '00.2.128', + '0.02.128', + '0.2.0128', + '0.2.128-01', + '0.2.128+', + '0.2.128-', + ' 0.2.128', + '0.2.128\n', + ])('rejects an invalid committed version when it is %j', (version) => { + const lock = packageEntry(version) + expect(() => resolveWasmBindgenVersion(lock)).toThrow() + }) + + it('resolves the real committed Cargo.lock when used as library input', () => { + const lock = readFileSync(committedLock, 'utf8') + const version = resolveWasmBindgenVersion(lock) + expect(committedVersion).toBeDefined() + expect(version).toBe(committedVersion) + }) +}) + +describe('wasm-bindgen resolver CLI', () => { + it('prints only the locked version when given the real Cargo.lock', () => { + const result = spawnSync(process.execPath, [resolver, committedLock], { + encoding: 'utf8', + }) + expect(result.status).toBe(0) + expect(committedVersion).toBeDefined() + expect(result.stdout).toBe(`${committedVersion}\n`) + expect(result.stderr).toBe('') + }) + + it.each([ + { args: [] }, + { args: ['Cargo.lock'] }, + { args: [committedLock, committedLock] }, + { args: [join(committedLock, 'absent')] }, + ])( + 'fails without version output when arguments are invalid %j', + ({ args }) => { + const result = spawnSync(process.execPath, [resolver, ...args], { + encoding: 'utf8', + }) + expect(result.status).toBe(1) + expect(result.stdout).toBe('') + expect(result.stderr).not.toBe('') + }, + ) + + it.each(['version = 4', '[[package]', packageEntry('bad')])( + 'fails without version output when the lock is invalid %j', + (lock) => { + const root = mkdtempSync(join(tmpdir(), 'wasm-bindgen-version-')) + roots.push(root) + const file = join(root, 'Cargo.lock') + writeFileSync(file, lock) + const result = spawnSync(process.execPath, [resolver, file], { + encoding: 'utf8', + }) + expect(result.status).toBe(1) + expect(result.stdout).toBe('') + expect(result.stderr).not.toBe('') + }, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/wasm-size-bindgen.test.ts b/packages/plugin-utils/src/__tests__/wasm-size-bindgen.test.ts new file mode 100644 index 000000000..6e718e5d0 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/wasm-size-bindgen.test.ts @@ -0,0 +1,142 @@ +import { readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'bun:test' + +import { cli, validateMetadata } from '../../../../wasm-size.js' +import { sizeFixture } from './wasm-size-fixture' + +const roots: string[] = [] + +function fixture() { + const input = sizeFixture(roots) + input.metadata.base.tools.wasmBindgen = + 'wasm-bindgen 0.2.128\n/base/bin/wasm-bindgen\nsha256:base' + input.metadata.candidate.wasmBindgenVersion = '0.2.129' + input.metadata.candidate.tools.wasmBindgen = + 'wasm-bindgen 0.2.129\n/candidate/bin/wasm-bindgen\nsha256:candidate' + return input +} + +afterEach(() => { + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }) +}) + +describe('per-tree bindgen ABI provenance', () => { + it('retains both CLI versions in passing reports when locked ABIs differ', () => { + const input = fixture() + const manifest = join(input.root, 'metadata.json') + const report = join(input.root, 'report.json') + const markdown = join(input.root, 'summary.md') + writeFileSync(manifest, JSON.stringify(input.metadata)) + const status = cli([ + '--base-pkg', + input.base, + '--candidate-pkg', + input.candidate, + '--metadata', + manifest, + '--report', + report, + '--summary', + markdown, + ]) + expect(status).toBe(0) + const result: unknown = JSON.parse(readFileSync(report, 'utf8')) + expect(result).toMatchObject({ passed: true, metadata: input.metadata }) + const summary = readFileSync(markdown, 'utf8') + expect(summary).toContain( + JSON.stringify(input.metadata.base.tools.wasmBindgen), + ) + expect(summary).toContain( + JSON.stringify(input.metadata.candidate.tools.wasmBindgen), + ) + }) + + it('accepts identical CLI records with matching equal ABIs and CRLF provenance', () => { + const input = fixture() + input.metadata.candidate.wasmBindgenVersion = '0.2.128' + input.metadata.base.tools.wasmBindgen = + 'wasm-bindgen 0.2.128\r\n/shared/bin/wasm-bindgen\r\nsha256:shared' + input.metadata.candidate.tools.wasmBindgen = + input.metadata.base.tools.wasmBindgen + const result = validateMetadata(input.metadata, input) + expect(result).toEqual(input.metadata) + }) + + it('rejects different CLI records when the committed ABI is equal', () => { + const input = fixture() + input.metadata.candidate.wasmBindgenVersion = '0.2.128' + input.metadata.candidate.tools.wasmBindgen = + 'wasm-bindgen 0.2.128\n/candidate/bin/wasm-bindgen\nsha256:candidate' + expect(() => validateMetadata(input.metadata, input)).toThrow( + 'tool mismatch: wasmBindgen', + ) + }) + + it.each(['base', 'candidate'] as const)( + 'rejects a CLI version inconsistent with the %s committed lock', + (side) => { + const input = fixture() + input.metadata[side].wasmBindgenVersion = '0.2.130' + expect(() => validateMetadata(input.metadata, input)).toThrow( + 'tool mismatch', + ) + }, + ) + + it.each(['', 'latest', '0.2', '0.2.129\n'])( + 'rejects invalid declared versions when given %j', + (version) => { + const input = fixture() + input.metadata.candidate.wasmBindgenVersion = version + expect(() => validateMetadata(input.metadata, input)).toThrow() + }, + ) + + it('rejects missing lock provenance when metadata uses the old schema', () => { + const input = fixture() + const { commit, tree, packageDir, tools } = input.metadata.candidate + const candidate = { commit, tree, packageDir, tools } + const metadata = { ...input.metadata, candidate } + expect(() => validateMetadata(metadata, input)).toThrow('keys') + }) + + it.each([ + '', + 'wasm-bindgen 0.2.128', + 'path\nwasm-bindgen 0.2.129', + 'wasm-bindgen 0.2.129 extra', + 'wasm-bindgen 0.2.1290', + ])( + 'rejects missing or wrong actual CLI version records when given %j', + (record) => { + const input = fixture() + input.metadata.candidate.tools.wasmBindgen = record + expect(() => validateMetadata(input.metadata, input)).toThrow() + }, + ) + + it.each(['rustc', 'wasmPack', 'wasmOpt', 'bun', 'node'] as const)( + 'still rejects %s mismatches when bindgen ABIs differ', + (tool) => { + const input = fixture() + input.metadata.candidate.tools[tool] = 'different binary' + expect(() => validateMetadata(input.metadata, input)).toThrow( + `tool mismatch: ${tool}`, + ) + }, + ) + + it.each(['rustc', 'wasmPack', 'wasmOpt', 'bun', 'node'] as const)( + 'still rejects blank %s provenance when bindgen ABIs differ', + (tool) => { + const input = fixture() + input.metadata.candidate.tools[tool] = ' ' + expect(() => validateMetadata(input.metadata, input)).toThrow( + `missing candidate tool ${tool}`, + ) + }, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/wasm-size-fixture.ts b/packages/plugin-utils/src/__tests__/wasm-size-fixture.ts new file mode 100644 index 000000000..81386d306 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/wasm-size-fixture.ts @@ -0,0 +1,56 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +export function sizeFixture(roots: string[]) { + const root = mkdtempSync(join(tmpdir(), 'wasm-size-')) + roots.push(root) + const base = join(root, 'base') + const candidate = join(root, 'candidate') + for (const directory of [base, candidate]) { + mkdirSync(directory) + writeFileSync(join(directory, 'index_bg.wasm'), 'abc') + writeFileSync(join(directory, 'index.js'), 'export const answer = 42\n') + } + const tools = { + rustc: 'rustc fixture', + wasmPack: 'wasm-pack fixture', + wasmBindgen: 'wasm-bindgen 0.2.128', + wasmOpt: 'wasm-opt fixture', + bun: 'bun fixture', + node: 'node fixture', + } + const metadata = { + jobId: 'fixture-job', + buildCommand: 'fixture release build and postprocess', + base: { + commit: 'a'.repeat(40), + tree: 'b'.repeat(40), + packageDir: base, + wasmBindgenVersion: '0.2.128', + tools: { ...tools }, + }, + candidate: { + commit: 'c'.repeat(40), + tree: 'd'.repeat(40), + packageDir: candidate, + wasmBindgenVersion: '0.2.128', + tools: { ...tools }, + }, + } + const manifest = join(root, 'metadata.json') + writeFileSync(manifest, JSON.stringify(metadata)) + const report = join(root, 'report.json') + const markdown = join(root, 'summary.md') + const args = [ + '--base-pkg', + base, + '--candidate-pkg', + candidate, + '--metadata', + manifest, + '--report', + report, + ] + return { root, base, candidate, metadata, manifest, report, markdown, args } +} diff --git a/packages/plugin-utils/src/__tests__/wasm-size-policy.test.ts b/packages/plugin-utils/src/__tests__/wasm-size-policy.test.ts new file mode 100644 index 000000000..e6a8d1c09 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/wasm-size-policy.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'bun:test' + +import config from '../../../../bindings/devup-ui-wasm/size-budget.json' +import { compare, validateConfig } from '../../../../wasm-size.js' + +const metrics = [ + ['wasm', 'raw'], + ['wasm', 'gzip'], + ['glue', 'raw'], + ['glue', 'gzip'], +] as const +const sizes = (value: number) => ({ + wasm: { raw: value, gzip: value }, + glue: { raw: value, gzip: value }, +}) + +describe('relative WASM size policy', () => { + it.each([9_000, 10_000, 10_001, 10_240])( + 'passes when all candidate metrics are %i bytes', + (value) => { + const base = sizes(10_000) + const candidate = sizes(value) + const result = compare(base, candidate, config) + expect(result.passed).toBe(true) + expect(result.metrics).toHaveLength(4) + expect(result.metrics.map((metric) => metric.limitBytes)).toEqual([ + 10_240, 10_240, 10_240, 10_240, + ]) + }, + ) + + it.each(metrics)( + 'fails only %s/%s when one metric exceeds the rounded bound', + (artifact, kind) => { + const candidate = sizes(10_000) + candidate[artifact][kind] = 10_241 + const result = compare(sizes(10_000), candidate, config) + expect(result.passed).toBe(false) + expect(result.metrics.filter((metric) => !metric.passed)).toEqual([ + expect.objectContaining({ + artifact, + kind, + limitBytes: 10_240, + deltaBytes: 241, + deltaPercent: 2.41, + }), + ]) + }, + ) + + it.each([ + [102_400, 103_424], + [102_401, 104_448], + ])('rounds %i base bytes up to %i', (before, limit) => { + const result = compare(sizes(before), sizes(before), config) + expect(result.metrics.map((metric) => metric.limitBytes)).toEqual([ + limit, + limit, + limit, + limit, + ]) + }) + + it.each(metrics)( + 'uses the reviewed allowance only for %s/%s when overridden', + (artifact, kind) => { + const policy = structuredClone(config) + policy[artifact][kind] = { growth: 0.1, roundBytes: 1 } + const candidate = sizes(10_000) + candidate[artifact][kind] = 11_000 + const result = compare(sizes(10_000), candidate, policy) + expect(result.passed).toBe(true) + expect(result.metrics.map((metric) => metric.limitBytes)).toEqual( + metrics.map(([name, size]) => + name === artifact && size === kind ? 11_000 : 10_240, + ), + ) + }, + ) + + it.each([ + { policy: undefined }, + { policy: null }, + { policy: {} }, + { policy: [] }, + { policy: { ...config, hiddenCap: 100 } }, + { + policy: { + wasm: { raw: 5779456, gzip: 2075648 }, + glue: { raw: 41984, gzip: 7168 }, + }, + }, + ])('rejects incomplete or absolute config %j', ({ policy }) => { + expect(() => validateConfig(policy)).toThrow() + }) + + it.each([-1, NaN, Infinity, '0.01'])( + 'rejects invalid growth %s', + (growth) => { + const raw = { growth, roundBytes: 1024 } + const policy = { ...config, wasm: { ...config.wasm, raw } } + expect(() => validateConfig(policy)).toThrow() + }, + ) + + it.each([0, -1, 1.5, Infinity])( + 'rejects invalid rounding %s', + (roundBytes) => { + const gzip = { growth: 0.01, roundBytes } + const policy = { ...config, glue: { ...config.glue, gzip } } + expect(() => validateConfig(policy)).toThrow() + }, + ) + + it.each([0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER])( + 'rejects invalid or unsafe measurement %s', + (value) => { + expect(() => compare(sizes(value), sizes(1), config)).toThrow() + }, + ) + + it.each([ + { base: undefined, candidate: sizes(1) }, + { base: sizes(1), candidate: undefined }, + { base: sizes(1), candidate: { wasm: { raw: 1, gzip: 1 } } }, + { base: { wasm: { raw: 1, gzip: 1 } }, candidate: sizes(1) }, + ])('rejects a missing measurement %j', ({ base, candidate }) => { + expect(() => compare(base, candidate, config)).toThrow() + }) +}) diff --git a/packages/plugin-utils/src/__tests__/wasm-size-sources-cli.test.ts b/packages/plugin-utils/src/__tests__/wasm-size-sources-cli.test.ts new file mode 100644 index 000000000..16bdf2ebc --- /dev/null +++ b/packages/plugin-utils/src/__tests__/wasm-size-sources-cli.test.ts @@ -0,0 +1,198 @@ +import { spawnSync } from 'node:child_process' +import { readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { afterEach, describe, expect, it } from 'bun:test' + +import { cli } from '../../../../wasm-size-sources.js' +import { sourceFixture } from './wasm-size-sources-fixture' + +const roots: string[] = [] +const resolver = fileURLToPath( + new URL('../../../../resolve-wasm-size-sources.js', import.meta.url), +) + +afterEach(() => { + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }) +}) + +function fixture(environment = process.env) { + const input = sourceFixture(roots, 'Source fixture', environment) + const githubEnvironment = { + GITHUB_EVENT_NAME: 'pull_request', + GITHUB_SHA: input.candidate, + GITHUB_WORKSPACE: input.root, + GITHUB_EVENT_PATH: join(input.root, 'event.json'), + GITHUB_ENV: join(input.root, 'environment'), + GITHUB_STEP_SUMMARY: join(input.root, 'summary.md'), + } + const event = { pull_request: { base: { sha: input.lagging } } } + writeFileSync(githubEnvironment.GITHUB_EVENT_PATH, JSON.stringify(event)) + writeFileSync(githubEnvironment.GITHUB_ENV, 'EXISTING=1\n') + writeFileSync(githubEnvironment.GITHUB_STEP_SUMMARY, 'previous\n') + return { ...input, environment: githubEnvironment } +} + +describe('WASM size source resolver CLI', () => { + it.each([ + 'GIT_DIR', + 'GIT_COMMON_DIR', + 'GIT_WORK_TREE', + 'GIT_INDEX_FILE', + 'GIT_OBJECT_DIRECTORY', + 'GIT_ALTERNATE_OBJECT_DIRECTORIES', + 'all', + ] as const)('honors the workspace when inheriting foreign %s', (name) => { + // Different authors produce genuinely different commit objects in each repo. + const foreign = sourceFixture(roots, 'Foreign source fixture') + const foreignEnvironment = { + GIT_DIR: join(foreign.root, '.git'), + GIT_COMMON_DIR: join(foreign.root, '.git'), + GIT_WORK_TREE: foreign.root, + GIT_INDEX_FILE: join(foreign.root, '.git', 'index'), + GIT_OBJECT_DIRECTORY: join(foreign.root, '.git', 'objects'), + GIT_ALTERNATE_OBJECT_DIRECTORIES: join(foreign.root, '.git', 'objects'), + } + const input = fixture({ ...process.env, ...foreignEnvironment }) + expect(foreign.candidate).not.toBe(input.candidate) + + const result = spawnSync('node', [resolver], { + cwd: foreign.root, + env: { + ...process.env, + ...input.environment, + ...(name === 'all' + ? foreignEnvironment + : { [name]: foreignEnvironment[name] }), + }, + encoding: 'utf8', + }) + + expect(result.status).toBe(0) + expect(result.stderr).toBe('') + expect(result.stdout).toBe( + `BASE_SHA=${input.base}\nCANDIDATE_SHA=${input.candidate}\n`, + ) + expect(readFileSync(input.environment.GITHUB_ENV, 'utf8')).toBe( + `EXISTING=1\nBASE_SHA=${input.base}\nCANDIDATE_SHA=${input.candidate}\n`, + ) + }) + + it('rejects a commit found only in an inherited foreign alternate object store', () => { + const input = fixture() + const foreign = sourceFixture(roots, 'Foreign source fixture') + + const result = spawnSync('node', [resolver], { + env: { + ...process.env, + ...input.environment, + GITHUB_SHA: foreign.candidate, + GIT_ALTERNATE_OBJECT_DIRECTORIES: join(foreign.root, '.git', 'objects'), + }, + encoding: 'utf8', + }) + + expect(result.status).toBe(1) + expect(result.stdout).toBe('') + expect(readFileSync(input.environment.GITHUB_ENV, 'utf8')).toBe( + 'EXISTING=1\n', + ) + }) + + it('appends authenticated sources before builds when called in-process', () => { + const input = fixture() + + const result = cli(input.environment) + + expect(result).toBe(0) + expect(readFileSync(input.environment.GITHUB_ENV, 'utf8')).toBe( + `EXISTING=1\nBASE_SHA=${input.base}\nCANDIDATE_SHA=${input.candidate}\n`, + ) + const summary = readFileSync(input.environment.GITHUB_STEP_SUMMARY, 'utf8') + expect(summary).toStartWith('previous\n') + expect(summary).toContain(input.base) + expect(summary).toContain(input.candidate) + }) + + it.each([ + 'GITHUB_EVENT_PATH', + 'GITHUB_WORKSPACE', + 'GITHUB_ENV', + 'GITHUB_STEP_SUMMARY', + ] as const)('rejects missing %s when called in-process', (name) => { + const input = fixture() + + expect(() => cli({ ...input.environment, [name]: '' })).toThrow() + }) + + it.each(['pull_request', 'push'])( + 'emits exact source assignments when invoked for %s', + (eventName) => { + const input = fixture() + const event = { before: input.base, after: input.candidate } + writeFileSync(input.environment.GITHUB_EVENT_PATH, JSON.stringify(event)) + + const result = spawnSync('node', [resolver], { + env: { + ...process.env, + ...input.environment, + GITHUB_EVENT_NAME: eventName, + }, + encoding: 'utf8', + }) + + expect(result.status).toBe(0) + expect(result.stdout).toBe( + `BASE_SHA=${input.base}\nCANDIDATE_SHA=${input.candidate}\n`, + ) + expect(result.stderr).toBe('') + const summary = readFileSync( + input.environment.GITHUB_STEP_SUMMARY, + 'utf8', + ) + expect(summary).toContain(input.base) + expect(summary).toContain(input.candidate) + }, + ) + + it.each(['root', 'missing', 'unsupported', 'json'] as const)( + 'reports visible failure without exporting sources when input is %s', + (invalid) => { + const input = fixture() + const environment = { ...process.env, ...input.environment } + switch (invalid) { + case 'root': + environment.GITHUB_SHA = input.lagging + break + case 'missing': + environment.GITHUB_SHA = 'f'.repeat(40) + break + case 'unsupported': + environment.GITHUB_EVENT_NAME = 'schedule' + break + case 'json': + writeFileSync(environment.GITHUB_EVENT_PATH, '{') + break + default: { + const exhaustive: never = invalid + throw new Error(String(exhaustive)) + } + } + + const result = spawnSync('node', [resolver], { + env: environment, + encoding: 'utf8', + }) + + expect(result.status).toBe(1) + expect(result.stdout).toBe('') + expect(result.stderr).not.toBe('') + expect(readFileSync(environment.GITHUB_ENV, 'utf8')).toBe('EXISTING=1\n') + expect(readFileSync(environment.GITHUB_STEP_SUMMARY, 'utf8')).toContain( + '## WASM size: FAIL - source resolution', + ) + }, + ) +}) diff --git a/packages/plugin-utils/src/__tests__/wasm-size-sources-fixture.ts b/packages/plugin-utils/src/__tests__/wasm-size-sources-fixture.ts new file mode 100644 index 000000000..f46908628 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/wasm-size-sources-fixture.ts @@ -0,0 +1,57 @@ +import { execFileSync } from 'node:child_process' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { gitEnvironment } from '../../../../wasm-size-sources.js' + +export function sourceFixture( + roots: string[], + author = 'Source fixture', + environment = process.env, +) { + const root = mkdtempSync(join(tmpdir(), 'wasm-size-sources-')) + roots.push(root) + const git = (args: readonly string[], input = '') => + execFileSync('git', ['-C', root, ...args], { + encoding: 'utf8', + input, + env: { + ...gitEnvironment(environment), + GIT_MASTER: '1', + GIT_AUTHOR_NAME: author, + GIT_AUTHOR_EMAIL: 'source@example.invalid', + GIT_COMMITTER_NAME: 'Source fixture', + GIT_COMMITTER_EMAIL: 'source@example.invalid', + GIT_AUTHOR_DATE: '2026-01-01T00:00:00Z', + GIT_COMMITTER_DATE: '2026-01-01T00:00:00Z', + }, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim() + git(['init', '--object-format=sha1']) + const tree = git(['mktree']) + const lagging = git(['commit-tree', tree], 'lagging base') + const base = git(['commit-tree', tree, '-p', lagging], 'advanced base') + const head = git(['commit-tree', tree, '-p', lagging], 'pull request head') + const candidate = git( + ['commit-tree', tree, '-p', base, '-p', head], + 'tested merge', + ) + const dangling = git(['commit-tree', tree, '-p', head], 'missing parent') + const missingParent = git( + ['commit-tree', tree, '-p', dangling], + 'orphaned candidate', + ) + const blob = git(['hash-object', '-w', '--stdin'], 'not a commit') + return { + root, + tree, + blob, + lagging, + base, + head, + candidate, + dangling, + missingParent, + } +} diff --git a/packages/plugin-utils/src/__tests__/wasm-size-sources.test.ts b/packages/plugin-utils/src/__tests__/wasm-size-sources.test.ts new file mode 100644 index 000000000..0d7b099f9 --- /dev/null +++ b/packages/plugin-utils/src/__tests__/wasm-size-sources.test.ts @@ -0,0 +1,170 @@ +import { rmSync } from 'node:fs' +import { join } from 'node:path' + +import { afterEach, describe, expect, it } from 'bun:test' + +import { + gitEnvironment, + resolveSources, +} from '../../../../wasm-size-sources.js' +import { sourceFixture } from './wasm-size-sources-fixture' + +const roots: string[] = [] +const fixture = () => sourceFixture(roots) +const missing = 'f'.repeat(40) + +afterEach(() => { + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }) +}) + +describe('WASM size source resolution', () => { + it('preserves non-local environment without mutating the input', () => { + const environment = { + ...process.env, + GIT_DIR: '/foreign/metadata', + git_work_tree: '/foreign/worktree', + GIT_OBJECT_DIRECTORY: '/foreign/objects', + GIT_ASKPASS: 'credential-helper', + SOURCE_ENV_SENTINEL: 'preserved', + } + const original = { ...environment } + + const result = gitEnvironment(environment) + + expect(result.GIT_DIR).toBeUndefined() + expect(result.git_work_tree).toBeUndefined() + expect(result.GIT_OBJECT_DIRECTORY).toBeUndefined() + expect(result.GIT_ASKPASS).toBe('credential-helper') + expect(result.PATH).toBe(environment.PATH) + expect(result.SOURCE_ENV_SENTINEL).toBe('preserved') + expect(environment).toEqual(original) + }) + + it('uses the tested merge first parent when the event base lags', () => { + const input = fixture() + const event = { pull_request: { base: { sha: input.lagging } } } + + const result = resolveSources( + { eventName: 'pull_request', sha: input.candidate, event }, + input.root, + ) + + expect(result).toEqual({ base: input.base, candidate: input.candidate }) + }) + + it('preserves before and after when the event is push', () => { + const input = fixture() + const event = { before: input.lagging, after: input.head } + + const result = resolveSources( + { eventName: 'push', sha: input.candidate, event }, + input.root, + ) + + expect(result).toEqual({ base: input.lagging, candidate: input.head }) + }) + + it('fails without event-base fallback when the candidate is a root commit', () => { + const input = fixture() + const event = { pull_request: { base: { sha: input.base } } } + + expect(() => + resolveSources( + { eventName: 'pull_request', sha: input.lagging, event }, + input.root, + ), + ).toThrow('no first parent') + }) + + it('fails when the first parent object is unavailable', () => { + const input = fixture() + rmSync( + join( + input.root, + '.git', + 'objects', + input.dangling.slice(0, 2), + input.dangling.slice(2), + ), + ) + + expect(() => + resolveSources( + { eventName: 'pull_request', sha: input.missingParent }, + input.root, + ), + ).toThrow() + }) + + it.each([ + { input: null }, + { input: undefined }, + { input: [] }, + { input: {} }, + { input: { eventName: 'workflow_dispatch' } }, + ])('rejects unsupported or missing source input %j', ({ input }) => { + const { root } = fixture() + + expect(() => resolveSources(input, root)).toThrow() + }) + + it.each([ + undefined, + null, + '', + 'HEAD', + 'a'.repeat(39), + `${'a'.repeat(40)}\n`, + missing, + '0'.repeat(40), + ])('rejects invalid or absent candidate commit %j', (sha) => { + const { root } = fixture() + + expect(() => + resolveSources({ eventName: 'pull_request', sha }, root), + ).toThrow() + }) + + it.each(['tree', 'blob'] as const)( + 'rejects an actual %s object as a commit', + (kind) => { + const input = fixture() + + expect(() => + resolveSources( + { eventName: 'pull_request', sha: input[kind] }, + input.root, + ), + ).toThrow('is not a commit') + }, + ) + + it.each(['before', 'after'] as const)( + 'authenticates push %s against actual Git objects', + (side) => { + const input = fixture() + const event = { + before: input.base, + after: input.candidate, + [side]: missing, + } + + expect(() => + resolveSources({ eventName: 'push', event }, input.root), + ).toThrow() + }, + ) + + it.each([ + undefined, + null, + {}, + { before: missing }, + { before: 'HEAD', after: 'HEAD' }, + ])('rejects missing or invalid push sources %j', (event) => { + const { root } = fixture() + + expect(() => resolveSources({ eventName: 'push', event }, root)).toThrow() + }) +}) diff --git a/packages/plugin-utils/src/__tests__/wasm-size.test.ts b/packages/plugin-utils/src/__tests__/wasm-size.test.ts new file mode 100644 index 000000000..a0f1c871d --- /dev/null +++ b/packages/plugin-utils/src/__tests__/wasm-size.test.ts @@ -0,0 +1,220 @@ +import { spawnSync } from 'node:child_process' +import { readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { gzipSync } from 'node:zlib' + +import { afterEach, describe, expect, it } from 'bun:test' + +import config from '../../../../bindings/devup-ui-wasm/size-budget.json' +import { + cli, + compare, + measure, + summary, + validateMetadata, +} from '../../../../wasm-size.js' +import { sizeFixture } from './wasm-size-fixture' + +const roots: string[] = [] +const checker = fileURLToPath( + new URL('../../../../verify-wasm-size.js', import.meta.url), +) + +const fixture = () => sizeFixture(roots) + +afterEach(() => { + for (const root of roots.splice(0)) + rmSync(root, { recursive: true, force: true }) +}) + +describe('WASM size measurement and provenance', () => { + it('reads real files with gzip9 sizes and SHA256 identities', () => { + const input = fixture() + const result = measure(input.base) + expect(result.wasm).toEqual({ + file: join(input.base, 'index_bg.wasm'), + raw: 3, + gzip: gzipSync(Buffer.from('abc'), { level: 9 }).length, + sha256: + 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad', + }) + expect(result.glue.raw).toBe( + Buffer.byteLength('export const answer = 42\n'), + ) + }) + + it.each(['missing', 'empty'])('fails when an artifact is %s', (state) => { + const input = fixture() + const path = join(input.base, 'index.js') + if (state === 'missing') rmSync(path) + else writeFileSync(path, '') + expect(() => measure(input.base)).toThrow() + }) + + it.each(['commit', 'tree'] as const)('rejects missing %s identity', (id) => { + const input = fixture() + input.metadata.candidate[id] = '' + expect(() => validateMetadata(input.metadata, input)).toThrow() + }) + + it.each(['jobId', 'buildCommand'] as const)( + 'rejects blank %s provenance', + (field) => { + const input = fixture() + input.metadata[field] = ' ' + expect(() => validateMetadata(input.metadata, input)).toThrow() + }, + ) + + it.each(['', 'different binary'])( + 'rejects invalid tool provenance %j', + (version) => { + const input = fixture() + input.metadata.candidate.tools.wasmOpt = version + expect(() => validateMetadata(input.metadata, input)).toThrow() + }, + ) + + it.each(['', 'base'])( + 'rejects invalid package directory provenance %j', + (directory) => { + const input = fixture() + input.metadata.candidate.packageDir = directory ? input.base : '' + expect(() => validateMetadata(input.metadata, input)).toThrow() + }, + ) + + it('rejects shared package directories when both metadata paths match', () => { + const input = fixture() + input.metadata.candidate.packageDir = input.base + const directories = { base: input.base, candidate: input.base } + expect(() => validateMetadata(input.metadata, directories)).toThrow() + }) + + it('records both source identities and hashes in the summary', () => { + const input = fixture() + const base = measure(input.base) + const candidate = measure(input.candidate) + const report = { + ...compare(base, candidate, config), + metadata: input.metadata, + measurements: { base, candidate }, + config, + measurement: { level: 9 }, + } + const markdown = summary(report) + for (const source of [input.metadata.base, input.metadata.candidate]) { + expect(markdown).toContain(source.commit) + expect(markdown).toContain(source.tree) + } + expect(markdown).toContain(base.wasm.sha256) + expect(markdown).toContain('| wasm/raw | 3 | 3 | 1024 | 0 | 0.000 | PASS |') + }) +}) + +describe('WASM size CLI', () => { + it.each([false, true])( + 'preserves reports when comparison failure is %s', + (failure) => { + const input = fixture() + if (failure) + writeFileSync( + join(input.candidate, 'index_bg.wasm'), + Buffer.alloc(2048), + ) + const args = [checker, ...input.args, '--summary', input.markdown] + const result = spawnSync('node', args, { encoding: 'utf8' }) + expect(result.status).toBe(failure ? 1 : 0) + const report: unknown = JSON.parse(readFileSync(input.report, 'utf8')) + expect(report).toMatchObject({ + passed: !failure, + metadata: input.metadata, + measurement: { level: 9, implementation: 'node:zlib.gzipSync' }, + }) + expect(readFileSync(input.markdown, 'utf8')).toContain( + failure ? '## WASM size: FAIL' : '## WASM size: PASS', + ) + }, + ) + + it.each([false, true])( + 'writes reports in-process with failure %s', + (failure) => { + const input = fixture() + if (failure) + writeFileSync( + join(input.candidate, 'index_bg.wasm'), + Buffer.alloc(2048), + ) + const policy = join(input.root, 'policy.json') + writeFileSync(policy, JSON.stringify(config)) + const args = [ + ...input.args, + '--config', + policy, + '--summary', + input.markdown, + ] + const status = cli(args) + expect(status).toBe(failure ? 1 : 0) + const report: unknown = JSON.parse(readFileSync(input.report, 'utf8')) + expect(report).toMatchObject({ passed: !failure, config }) + }, + ) + + it('appends when GITHUB_STEP_SUMMARY supplies the destination', () => { + const input = fixture() + writeFileSync(input.markdown, 'previous\n') + const status = cli(input.args, { GITHUB_STEP_SUMMARY: input.markdown }) + expect(status).toBe(0) + expect(readFileSync(input.markdown, 'utf8')).toStartWith( + 'previous\n## WASM size: PASS', + ) + }) + + it.each([ + { args: [] }, + { args: ['--write'] }, + { args: ['--from', 'old.json'] }, + ])('rejects absent or obsolete arguments %j', ({ args }) => { + expect(() => cli(args, {})).toThrow() + }) + + it.each([ + 'metadata', + 'config', + 'summary', + 'artifact', + 'collision', + 'overwrite', + ] as const)('rejects invalid %s input without skipping', (invalid) => { + const input = fixture() + const args = [...input.args, '--summary', input.markdown] + switch (invalid) { + case 'metadata': + writeFileSync(input.manifest, '{') + break + case 'config': + args.push('--config', join(input.root, 'absent.json')) + break + case 'summary': + args.splice(-2) + break + case 'artifact': + rmSync(join(input.base, 'index_bg.wasm')) + break + case 'collision': + args.splice(-1, 1, input.report) + break + case 'overwrite': + args.splice(-1, 1, input.manifest) + break + default: { + const exhaustive: never = invalid + throw new Error(String(exhaustive)) + } + } + expect(() => cli(args, {})).toThrow() + }) +}) diff --git a/render-benchmark-readme.js b/render-benchmark-readme.js index 37418e76d..09fbdabb1 100644 --- a/render-benchmark-readme.js +++ b/render-benchmark-readme.js @@ -1,5 +1,7 @@ // Writes the benchmark section of README.md and README_ko.md from one checked // result file, so both languages publish the same run and the same numbers. +// Every row is the median of the same number of cold samples, shown with its +// range. `benchmark-check.js --write-results` refreshes the file from a run. // // bun render-benchmark-readme.js rewrite the sections // bun render-benchmark-readme.js --check fail when a README differs from them @@ -9,80 +11,73 @@ const START = '' const END = '' const results = JSON.parse(readFileSync('benchmark-results.json', 'utf8')) +const rowsOf = (table) => results.rows.filter((row) => row.table === table) const bytes = (value) => `${value.toLocaleString('en-US')} bytes` const seconds = (value) => `${value.toFixed(2)}s` +const range = (row) => `${seconds(row.min)} - ${seconds(row.max)}` const list = (samples) => samples.map(seconds).join(', ') const TEXT = { en: { file: 'README.md', - intro: ({ run }) => - `[Latest CI benchmark](${run.url}) (commit \`${run.commit}\`, Devup UI ${run.devupUi}) on \`${run.runner}\` with Next.js ${run.next}. All Next.js builds use the native TypeScript 7 CLI for type checking. The English and Korean tables are generated from the same checked result file, \`benchmark-results.json\`.`, + intro: ({ run, samples }) => + `[Latest CI benchmark](${run.url}) (commit \`${run.commit}\`, Devup UI ${run.devupUi}) on \`${run.runner}\` with Next.js ${run.next}. Every row is the median of ${samples} cold builds that ran in rotating order, shown with the range of the samples. All Next.js builds use the native TypeScript 7 CLI for type checking. The English and Korean tables are generated from the same checked result file, \`benchmark-results.json\`.`, sizes: - 'Build Size is every byte of the build output directory (`.next`, or `dist` for vinext); CSS Size is the emitted `.css` files alone, measured separately.', - webpack: 'Webpack values are one cold build:', - turbopack: - 'Turbopack values are medians of six cold builds in alternating order:', + "Build Size is every byte of the build output directory (`.next`, or `dist` for vinext), including caches and server output. The checked table retains its original historical CSS measurements, which recursively counted `.css` files throughout that output. New runs count only browser-shipped CSS: `.next/static/**/*.css` (Webpack `static/css` and Turbopack `static/chunks`) or vinext `dist/client/**/*.css`, excluding caches and server CSS. A missing browser-assets directory is an error; an existing directory with no CSS counts zero. Approved CSS measurement corrections are limited to explicitly named `devup-ui`, `devup-ui-single` and `vinext-devup-ui` rows. `bun benchmark-check.js --write-css-baseline devup-ui` retains the per-file mode; `bun benchmark-check.js --write-css-baseline devup-ui-single,vinext-devup-ui` selects only the other two rows in one comma-separated argument. Only selected existing rows' CSS bytes change; unlike `--write-baseline`, this preserves sample count, build bytes, ratios, tolerances, all unselected rows and calibration history. Missing or unsupported names and conflicting writer modes are rejected.", + webpack: 'Webpack:', + turbopack: 'Turbopack:', columns: { library: 'Library', version: 'Version', - time: 'Build Time', - median: 'Median Build Time', + time: 'Median Build Time', + range: 'Range', size: 'Build Size', css: 'CSS Size', }, - note: ({ turbopack }) => { - const [tailwind, direct, stylesheet] = turbopack - const range = (row) => [ - Math.min(...row.samples), - Math.max(...row.samples), - ] + note: ({ samples }, [tailwind, direct, stylesheet]) => { + const spread = (row) => [row.min, row.max] const overlap = - range(tailwind)[0] <= range(direct)[1] && - range(direct)[0] <= range(tailwind)[1] + spread(tailwind)[0] <= spread(direct)[1] && + spread(direct)[0] <= spread(tailwind)[1] const gap = tailwind.median - direct.median - return `${overlap ? 'The Turbopack ranges overlap' : 'The Turbopack ranges do not overlap'}, so the direct-API median is ${Math.abs(gap).toFixed(2)}s (${Math.abs((gap / tailwind.median) * 100).toFixed(1)}%) ${gap >= 0 ? 'ahead of' : 'behind'} Tailwind on this fixture${overlap ? ' and the two are effectively at parity' : ''}. The six cold samples were Tailwind \`${list(tailwind.samples)}\`, direct Devup UI \`${list(direct.samples)}\`, and static \`.css.ts\` \`${list(stylesheet.samples)}\`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. Every \`.css.ts\` module runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one.` + return `${overlap ? 'The Turbopack ranges overlap' : 'The Turbopack ranges do not overlap'}, so the direct-API median is ${Math.abs(gap).toFixed(2)}s (${Math.abs((gap / tailwind.median) * 100).toFixed(1)}%) ${gap >= 0 ? 'ahead of' : 'behind'} Tailwind on this fixture${overlap ? ' and the two are effectively at parity' : ''}. The ${samples} cold samples were Tailwind \`${list(tailwind.samples)}\`, direct Devup UI \`${list(direct.samples)}\`, and static \`.css.ts\` \`${list(stylesheet.samples)}\`. The fixtures have comparable app shapes, not pixel-identical styling: Tailwind styles the leading paragraph and button more heavily, while Devup UI exercises typed component/style props. Treat these as build-pipeline results rather than a per-rule microbenchmark. Every \`.css.ts\` module runs on the full Boa evaluator, which adds about 20 ms for the first module and under 1 ms for each further one. CI compares the Devup UI rows with a checked baseline (see \`benchmark-gate.js\` for the rule) and fails on a regression beyond the measured noise.` }, }, ko: { file: 'README_ko.md', - intro: ({ run }) => - `[최신 CI 벤치마크](${run.url}) (커밋 \`${run.commit}\`, Devup UI ${run.devupUi})는 \`${run.runner}\`에서 Next.js ${run.next}로 실행했습니다. 모든 Next.js 빌드는 네이티브 TypeScript 7 CLI로 타입 검사를 수행합니다. 영어와 한국어 표는 같은 결과 파일 \`benchmark-results.json\`에서 생성합니다.`, + intro: ({ run, samples }) => + `[최신 CI 벤치마크](${run.url}) (커밋 \`${run.commit}\`, Devup UI ${run.devupUi})는 \`${run.runner}\`에서 Next.js ${run.next}로 실행했습니다. 모든 행은 순서를 돌려 가며 측정한 cold build ${samples}회의 중앙값이며 샘플의 범위를 함께 보여줍니다. 모든 Next.js 빌드는 네이티브 TypeScript 7 CLI로 타입 검사를 수행합니다. 영어와 한국어 표는 같은 결과 파일 \`benchmark-results.json\`에서 생성합니다.`, sizes: - '빌드 사이즈는 빌드 출력 디렉터리(`.next`, vinext는 `dist`)의 모든 바이트이고, CSS 사이즈는 출력된 `.css` 파일만 따로 잰 값입니다.', - webpack: 'Webpack 값은 cold build 1회 결과입니다.', - turbopack: - 'Turbopack 값은 실행 순서를 번갈아 측정한 cold build 6회의 중앙값입니다.', + '빌드 사이즈는 캐시와 서버 출력을 포함한 빌드 출력 디렉터리(`.next`, vinext는 `dist`)의 모든 바이트입니다. 체크인된 표는 출력 전체의 `.css` 파일을 재귀적으로 합산한 기존 CSS 측정값을 그대로 보존합니다. 새 실행은 브라우저에 제공되는 CSS만 측정합니다: `.next/static/**/*.css`(Webpack `static/css`, Turbopack `static/chunks`) 또는 vinext `dist/client/**/*.css`. 캐시와 서버 CSS는 제외합니다. 브라우저 에셋 디렉터리가 없으면 오류이며, 존재하지만 CSS가 없으면 0입니다. 승인된 CSS 측정 보정은 명시적으로 지정한 `devup-ui`, `devup-ui-single`, `vinext-devup-ui` 행으로 제한됩니다. `bun benchmark-check.js --write-css-baseline devup-ui`는 기존 per-file 모드를 유지하며, `bun benchmark-check.js --write-css-baseline devup-ui-single,vinext-devup-ui`는 쉼표로 구분한 인자 하나로 나머지 두 행만 선택합니다. 선택한 기존 행의 CSS 바이트만 바꾸고, `--write-baseline`과 달리 샘플 수, 빌드 바이트, 비율, 허용 오차, 선택하지 않은 모든 행과 보정 이력을 보존합니다. 이름 누락, 지원하지 않는 이름, 다른 쓰기 모드와의 혼용은 오류입니다.', + webpack: 'Webpack:', + turbopack: 'Turbopack:', columns: { library: '라이브러리', version: '버전', - time: '빌드 시간', - median: '빌드 시간 중앙값', + time: '빌드 시간 중앙값', + range: '범위', size: '빌드 사이즈', css: 'CSS 사이즈', }, - note: ({ turbopack }) => { - const [tailwind, direct, stylesheet] = turbopack - const range = (row) => [ - Math.min(...row.samples), - Math.max(...row.samples), - ] + note: ({ samples }, [tailwind, direct, stylesheet]) => { + const spread = (row) => [row.min, row.max] const overlap = - range(tailwind)[0] <= range(direct)[1] && - range(direct)[0] <= range(tailwind)[1] + spread(tailwind)[0] <= spread(direct)[1] && + spread(direct)[0] <= spread(tailwind)[1] const gap = tailwind.median - direct.median - return `Turbopack 측정 범위는 ${overlap ? '서로 겹치므로' : '겹치지 않으므로'} direct API 중앙값은 이 fixture에서 Tailwind보다 ${Math.abs(gap).toFixed(2)}s(${Math.abs((gap / tailwind.median) * 100).toFixed(1)}%) ${gap >= 0 ? '빠르며' : '느리며'}${overlap ? ' 사실상 동률입니다' : ''}. cold 샘플 6개는 Tailwind \`${list(tailwind.samples)}\`, direct Devup UI \`${list(direct.samples)}\`, static \`.css.ts\` \`${list(stylesheet.samples)}\`입니다. fixture는 앱 구조만 비슷하고 스타일이 픽셀 단위로 같지는 않습니다. Tailwind는 첫 문단과 버튼에 더 많은 스타일을 주고, Devup UI는 타입이 있는 컴포넌트/스타일 props를 사용합니다. 규칙 단위 마이크로벤치마크가 아니라 빌드 파이프라인 결과로 보세요. 모든 \`.css.ts\`는 full Boa evaluator로 처리하며, 첫 모듈에 약 20ms, 이후 모듈마다 1ms 미만이 더 듭니다.` + return `Turbopack 측정 범위는 ${overlap ? '서로 겹치므로' : '겹치지 않으므로'} direct API 중앙값은 이 fixture에서 Tailwind보다 ${Math.abs(gap).toFixed(2)}s(${Math.abs((gap / tailwind.median) * 100).toFixed(1)}%) ${gap >= 0 ? '빠르며' : '느리며'}${overlap ? ' 사실상 동률입니다' : ''}. cold 샘플 ${samples}개는 Tailwind \`${list(tailwind.samples)}\`, direct Devup UI \`${list(direct.samples)}\`, static \`.css.ts\` \`${list(stylesheet.samples)}\`입니다. fixture는 앱 구조만 비슷하고 스타일이 픽셀 단위로 같지는 않습니다. Tailwind는 첫 문단과 버튼에 더 많은 스타일을 주고, Devup UI는 타입이 있는 컴포넌트/스타일 props를 사용합니다. 규칙 단위 마이크로벤치마크가 아니라 빌드 파이프라인 결과로 보세요. 모든 \`.css.ts\`는 full Boa evaluator로 처리하며, 첫 모듈에 약 20ms, 이후 모듈마다 1ms 미만이 더 듭니다. CI는 Devup UI 행을 체크인된 기준값과 비교해 측정된 노이즈를 넘는 회귀에서 실패합니다(규칙은 \`benchmark-gate.js\`).` }, }, } -function table(text, rows, timeColumn, timeOf) { +function table(text, rows) { const columns = text.columns const header = [ columns.library, columns.version, - timeColumn, + columns.time, + columns.range, columns.size, columns.css, ] @@ -91,7 +86,8 @@ function table(text, rows, timeColumn, timeOf) { return [ bold(row.label), row.version, - bold(seconds(timeOf(row))), + bold(seconds(row.median)), + range(row), bytes(row.bytes), bytes(row.cssBytes), ] @@ -117,13 +113,13 @@ function section(text) { '', text.webpack, '', - table(text, results.webpack, text.columns.time, (row) => row.seconds), + table(text, rowsOf('webpack')), '', text.turbopack, '', - table(text, results.turbopack, text.columns.median, (row) => row.median), + table(text, rowsOf('turbopack')), '', - text.note(results), + text.note(results, rowsOf('turbopack')), END, ].join('\n') } diff --git a/resolve-wasm-bindgen.js b/resolve-wasm-bindgen.js new file mode 100644 index 000000000..0fe219ca4 --- /dev/null +++ b/resolve-wasm-bindgen.js @@ -0,0 +1,16 @@ +// bun resolve-wasm-bindgen.js /absolute/path/to/Cargo.lock +import { readFileSync } from 'node:fs' +import { isAbsolute } from 'node:path' + +import { resolveWasmBindgenVersion } from './wasm-bindgen-version.js' + +try { + const args = process.argv.slice(2) + if (args.length !== 1 || !isAbsolute(args[0])) + throw new Error('expected exactly one absolute Cargo.lock path') + console.info(resolveWasmBindgenVersion(readFileSync(args[0], 'utf8'))) +} catch (error) { + if (!(error instanceof Error)) throw error + console.error('resolve-wasm-bindgen: failed', error.message) + process.exitCode = 1 +} diff --git a/resolve-wasm-size-sources.js b/resolve-wasm-size-sources.js new file mode 100644 index 000000000..b73bb9569 --- /dev/null +++ b/resolve-wasm-size-sources.js @@ -0,0 +1,16 @@ +import { appendFileSync } from 'node:fs' + +import { cli } from './wasm-size-sources.js' + +try { + process.exitCode = cli(process.env) +} catch (error) { + if (!(error instanceof Error)) throw error + console.error('resolve-wasm-size-sources: failed', error.message) + if (process.env.GITHUB_STEP_SUMMARY) + appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + `## WASM size: FAIL - source resolution\n\n\`\`\`text\n${error.message}\n\`\`\`\n\n`, + ) + process.exitCode = 1 +} diff --git a/verify-wasm-size.js b/verify-wasm-size.js new file mode 100644 index 000000000..22b9cecc1 --- /dev/null +++ b/verify-wasm-size.js @@ -0,0 +1,12 @@ +// node verify-wasm-size.js --base-pkg DIR --candidate-pkg DIR --metadata FILE +// --report FILE [--summary FILE] [--config FILE] +// Without --summary, GITHUB_STEP_SUMMARY is required. No baseline writer exists. +import { cli } from './wasm-size.js' + +try { + process.exitCode = cli(process.argv.slice(2)) +} catch (error) { + if (!(error instanceof Error)) throw error + console.error('verify-wasm-size: failed', error.message) + process.exitCode = 1 +} diff --git a/wasm-bindgen-version.js b/wasm-bindgen-version.js new file mode 100644 index 000000000..47d4f0508 --- /dev/null +++ b/wasm-bindgen-version.js @@ -0,0 +1,21 @@ +export const wasmBindgenVersionPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?![\s\S])/ + +export function resolveWasmBindgenVersion(lockText) { + const lock = globalThis.Bun.TOML.parse(lockText) + if (!Array.isArray(lock.package)) + throw new Error('resolve-wasm-bindgen: missing package array') + const versions = new Set() + for (const entry of lock.package) { + if (entry?.name !== 'wasm-bindgen') continue + const version = entry.version + if (typeof version !== 'string' || !wasmBindgenVersionPattern.test(version)) + throw new Error('resolve-wasm-bindgen: invalid wasm-bindgen version') + versions.add(version) + } + if (versions.size !== 1) + throw new Error( + 'resolve-wasm-bindgen: expected one unique wasm-bindgen version', + ) + return [...versions][0] +} diff --git a/wasm-size-sources.js b/wasm-size-sources.js new file mode 100644 index 000000000..bd536e64e --- /dev/null +++ b/wasm-size-sources.js @@ -0,0 +1,97 @@ +import { execFileSync } from 'node:child_process' +import { appendFileSync, readFileSync } from 'node:fs' + +function requireInput(condition, message) { + if (!condition) throw new Error(`resolve-wasm-size-sources: ${message}`) +} + +export function gitEnvironment(environment) { + // This query needs no repository, even when inherited metadata is foreign. + const localNames = new Set( + execFileSync('git', ['rev-parse', '--local-env-vars'], { + env: environment, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) + .trim() + .split(/\r?\n/), + ) + return Object.fromEntries( + Object.entries(environment).filter( + ([name]) => !localNames.has(name.toUpperCase()), + ), + ) +} + +function git(cwd, args) { + return execFileSync('git', ['--no-replace-objects', ...args], { + cwd, + env: gitEnvironment(process.env), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }).trim() +} + +function commit(cwd, sha) { + requireInput( + typeof sha === 'string' && /^[a-f0-9]{40}(?![\s\S])/.test(sha), + 'expected an exact 40-character commit ID', + ) + requireInput( + git(cwd, ['cat-file', '-t', sha]) === 'commit', + `${sha} is not a commit`, + ) + return sha +} + +export function resolveSources(input, cwd) { + requireInput( + input !== null && typeof input === 'object' && !Array.isArray(input), + 'missing source input', + ) + switch (input.eventName) { + case 'pull_request': { + const candidate = commit(cwd, input.sha) + const header = git(cwd, ['cat-file', '-p', candidate]).split('\n\n', 1)[0] + const parent = header.match(/^parent ([a-f0-9]{40})$/m)?.[1] + requireInput(parent, `${candidate} has no first parent`) + return { base: commit(cwd, parent), candidate } + } + case 'push': + return { + base: commit(cwd, input.event?.before), + candidate: commit(cwd, input.event?.after), + } + default: + throw new Error('resolve-wasm-size-sources: unsupported event') + } +} + +export function cli(environment) { + for (const name of [ + 'GITHUB_EVENT_PATH', + 'GITHUB_WORKSPACE', + 'GITHUB_ENV', + 'GITHUB_STEP_SUMMARY', + ]) + requireInput( + typeof environment[name] === 'string' && environment[name].trim(), + `required ${name}`, + ) + const sources = resolveSources( + { + eventName: environment.GITHUB_EVENT_NAME, + sha: environment.GITHUB_SHA, + event: JSON.parse(readFileSync(environment.GITHUB_EVENT_PATH, 'utf8')), + }, + environment.GITHUB_WORKSPACE, + ) + const assignments = `BASE_SHA=${sources.base}\nCANDIDATE_SHA=${sources.candidate}\n` + appendFileSync( + environment.GITHUB_STEP_SUMMARY, + `## WASM size sources\n\nBase: \`${sources.base}\`\n\nCandidate: \`${sources.candidate}\`\n\n`, + ) + appendFileSync(environment.GITHUB_ENV, assignments) + console.info(assignments.trim()) + return 0 +} diff --git a/wasm-size.js b/wasm-size.js new file mode 100644 index 000000000..76118ebd8 --- /dev/null +++ b/wasm-size.js @@ -0,0 +1,226 @@ +import { createHash } from 'node:crypto' +import { + appendFileSync, + readFileSync, + realpathSync, + writeFileSync, +} from 'node:fs' +import { join, resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { gzipSync } from 'node:zlib' + +import { wasmBindgenVersionPattern } from './wasm-bindgen-version.js' + +const artifacts = { wasm: 'index_bg.wasm', glue: 'index.js' } +const tools = ['rustc', 'wasmPack', 'wasmBindgen', 'wasmOpt', 'bun', 'node'] +const readJson = (file) => JSON.parse(readFileSync(file, 'utf8')) +const object = (value) => + value !== null && typeof value === 'object' && !Array.isArray(value) +const text = (value) => typeof value === 'string' && value.trim().length > 0 +const bytes = (value) => Number.isSafeInteger(value) && value > 0 + +function requireInput(condition, message) { + if (!condition) throw new Error(`verify-wasm-size: ${message}`) +} + +function keys(value, expected, label) { + const matches = object(value) && Object.keys(value).sort().join() + requireInput(matches === [...expected].sort().join(), `invalid ${label} keys`) +} + +export function validateConfig(config) { + keys(config, ['wasm', 'glue'], 'config') + for (const name of Object.keys(artifacts)) { + keys(config[name], ['raw', 'gzip'], `config.${name}`) + for (const kind of ['raw', 'gzip']) { + const policy = config[name][kind] + const label = `${name}.${kind}` + keys(policy, ['growth', 'roundBytes'], `config.${label}`) + const growth = Number.isFinite(policy.growth) && policy.growth >= 0 + requireInput(growth, `invalid ${label} growth`) + requireInput(bytes(policy.roundBytes), `invalid ${label} roundBytes`) + } + } + return config +} + +export function validateMetadata(metadata, directories) { + keys(metadata, ['jobId', 'buildCommand', 'base', 'candidate'], 'metadata') + const provenance = text(metadata.jobId) && text(metadata.buildCommand) + requireInput(provenance, 'missing job/build provenance') + for (const side of ['base', 'candidate']) { + const source = metadata[side] + keys( + source, + ['commit', 'tree', 'packageDir', 'wasmBindgenVersion', 'tools'], + `metadata.${side}`, + ) + for (const id of ['commit', 'tree']) { + const valid = + typeof source[id] === 'string' && /^[a-f0-9]{40}$/.test(source[id]) + requireInput(valid, `invalid ${side}.${id}`) + } + const path = source.packageDir + const matches = + text(path) && realpathSync(path) === realpathSync(directories[side]) + requireInput(matches, `${side} packageDir does not match CLI`) + keys(source.tools, tools, `${side}.tools`) + for (const tool of tools) { + requireInput(text(source.tools[tool]), `missing ${side} tool ${tool}`) + if ( + tool === 'wasmBindgen' && + source.wasmBindgenVersion !== metadata.base.wasmBindgenVersion + ) + continue + const matches = source.tools[tool] === metadata.base.tools[tool] + requireInput(matches, `tool mismatch: ${tool}`) + } + const version = source.wasmBindgenVersion + requireInput( + typeof version === 'string' && wasmBindgenVersionPattern.test(version), + `invalid ${side}.wasmBindgenVersion`, + ) + requireInput( + source.tools.wasmBindgen.split(/\r?\n/, 1)[0] === + `wasm-bindgen ${version}`, + `tool mismatch: ${side} wasmBindgen does not match committed lock version`, + ) + } + const distinct = + realpathSync(directories.base) !== realpathSync(directories.candidate) + requireInput(distinct, 'base and candidate package directories must differ') + return metadata +} + +export function measure(directory) { + return Object.fromEntries( + Object.entries(artifacts).map(([name, file]) => { + const path = join(realpathSync(directory), file) + const content = readFileSync(path) + requireInput(bytes(content.length), `empty artifact: ${path}`) + const sizes = { + file: path, + sha256: createHash('sha256').update(content).digest('hex'), + raw: content.length, + gzip: gzipSync(content, { level: 9 }).length, + } + return [name, sizes] + }), + ) +} + +export function compare(base, candidate, config) { + validateConfig(config) + const metrics = [] + for (const name of Object.keys(artifacts)) { + for (const kind of ['raw', 'gzip']) { + const before = base?.[name]?.[kind] + const after = candidate?.[name]?.[kind] + requireInput( + bytes(before) && bytes(after), + `invalid measurement: ${name}.${kind}`, + ) + const { growth, roundBytes } = config[name][kind] + const limit = Math.ceil((before * (1 + growth)) / roundBytes) * roundBytes + requireInput(bytes(limit), `unsafe limit: ${name}.${kind}`) + metrics.push({ + artifact: name, + kind, + baseBytes: before, + candidateBytes: after, + limitBytes: limit, + deltaBytes: after - before, + deltaPercent: ((after - before) / before) * 100, + growth, + roundBytes, + passed: after <= limit, + }) + } + } + return { passed: metrics.every((metric) => metric.passed), metrics } +} + +export function summary(report) { + const { metadata, measurements, config, measurement } = report + const evidence = { metadata, measurements, config, measurement } + const lines = [ + `## WASM size: ${report.passed ? 'PASS' : 'FAIL'}`, + '', + '| Metric | Base bytes | Candidate bytes | Limit bytes | Delta bytes | Delta % | Result |', + '| --- | ---: | ---: | ---: | ---: | ---: | --- |', + ...report.metrics.map( + (row) => + `| ${row.artifact}/${row.kind} | ${row.baseBytes} | ${row.candidateBytes} | ${row.limitBytes} | ${row.deltaBytes} | ${row.deltaPercent.toFixed(3)} | ${row.passed ? 'PASS' : 'FAIL'} |`, + ), + '', + 'Limits: ceil(baseBytes * (1 + growth) / roundBytes) * roundBytes; policies recorded below.', + '', + '```json', + JSON.stringify(evidence, null, 2), + '```', + '', + ] + return lines.join('\n') +} + +export function cli(args, environment = process.env) { + const names = [ + 'base-pkg', + 'candidate-pkg', + 'metadata', + 'config', + 'report', + 'summary', + ] + const options = Object.fromEntries( + names.map((name) => [name, { type: 'string' }]), + ) + const { values } = parseArgs({ args, options }) + for (const flag of ['base-pkg', 'candidate-pkg', 'metadata', 'report']) + requireInput(text(values[flag]), `required --${flag}`) + const summaryFile = values.summary ?? environment.GITHUB_STEP_SUMMARY + requireInput(text(summaryFile), 'required --summary or GITHUB_STEP_SUMMARY') + const defaultConfig = new URL( + './bindings/devup-ui-wasm/size-budget.json', + import.meta.url, + ) + const configFile = values.config ?? defaultConfig + const paths = [values.report, summaryFile].map((path) => resolve(path)) + const directories = { + base: values['base-pkg'], + candidate: values['candidate-pkg'], + } + // Reject outputs that could overwrite evidence or policy before writing anything. + const files = Object.values(directories).flatMap((dir) => + Object.values(artifacts).map((file) => join(dir, file)), + ) + const inputs = [values.metadata, configFile, ...files] + const safe = inputs.every((file) => !paths.includes(realpathSync(file))) + requireInput( + paths[0] !== paths[1] && safe, + 'outputs must not overwrite inputs or each other', + ) + const config = validateConfig(readJson(configFile)) + const metadata = validateMetadata(readJson(values.metadata), directories) + const base = measure(directories.base) + const candidate = measure(directories.candidate) + const report = { + schemaVersion: 1, + ...compare(base, candidate, config), + metadata, + measurements: { base, candidate }, + config, + measurement: { + implementation: 'node:zlib.gzipSync', + level: 9, + node: process.version, + zlib: process.versions.zlib, + }, + } + const markdown = summary(report) + writeFileSync(values.report, JSON.stringify(report, null, 2) + '\n') + if (values.summary) writeFileSync(summaryFile, markdown) + else appendFileSync(summaryFile, markdown) + console.info(markdown) + return report.passed ? 0 : 1 +}