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
+}