diff --git a/.github/workflows/web-e2e.yml b/.github/workflows/web-e2e.yml new file mode 100644 index 0000000..8cc5321 --- /dev/null +++ b/.github/workflows/web-e2e.yml @@ -0,0 +1,100 @@ +name: Web E2E + +on: + pull_request: + branches: [main] + paths: + - 'web/**' + - 'src/runtime/**' + - '.github/workflows/web-e2e.yml' + workflow_dispatch: + +jobs: + playwright: + name: Build SPA + boot backend + Playwright + runs-on: ubuntu-latest + timeout-minutes: 25 + + env: + OLLAMA_API_KEY: "" + OPENROUTER_API_KEY: "" + AZURE_OPENAI_KEY: "" + AZURE_DEPLOYMENT: "" + AZURE_ENDPOINT: https://ci-dummy.example/ + EXTERNAL_MCP_URL: https://ci-dummy.example/ + EXT_TOKEN: ci-dummy + + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + + - name: Set up Python 3.11 + uses: actions/setup-python@v6.2.0 + with: + python-version: "3.11" + + - name: Set up uv + uses: astral-sh/setup-uv@v6.7.0 + with: + version: "0.11.7" + enable-cache: true + + - name: Install Python deps (locked) + run: uv sync --frozen --extra dev + + - name: Set up Node 22 + uses: actions/setup-node@v6.0.0 + with: + node-version: "22" + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install web deps (locked) + working-directory: web + run: npm ci + + - name: Build SPA + working-directory: web + run: npm run build + + - name: Install Playwright browsers + working-directory: web + run: npx playwright install --with-deps chromium + + - name: Boot backend (serves SPA + API) + run: | + mkdir -p logs + nohup uv run uvicorn runtime.api:app --port 8000 > logs/uvicorn.log 2>&1 & + echo $! > .backend.pid + for i in $(seq 1 60); do + if curl -sf http://localhost:8000/api/v1/ui/hints >/dev/null; then + echo "backend ready after ${i}s" + exit 0 + fi + sleep 1 + done + echo "backend failed to start" + tail -200 logs/uvicorn.log + exit 1 + + - name: Run Playwright + working-directory: web + env: + E2E_BASE_URL: http://localhost:8000 + run: npx playwright test --reporter=list + + - name: Stop backend + if: always() + run: | + if [ -f .backend.pid ]; then kill "$(cat .backend.pid)" 2>/dev/null || true; fi + + - name: Upload Playwright report (on failure) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + web/playwright-report + web/test-results + logs/uvicorn.log + retention-days: 7 diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml new file mode 100644 index 0000000..46014ab --- /dev/null +++ b/.github/workflows/web.yml @@ -0,0 +1,59 @@ +name: Web CI + +on: + push: + branches: [main] + paths: + - 'web/**' + - '.github/workflows/web.yml' + pull_request: + branches: [main] + paths: + - 'web/**' + - '.github/workflows/web.yml' + +defaults: + run: + working-directory: web + +jobs: + quality: + name: Lint / Type-check / Test / Build / Size + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + + - name: Set up Node 22 + uses: actions/setup-node@v6.0.0 + with: + node-version: "22" + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install (locked) + run: npm ci + + - name: Lint + run: npm run lint + + - name: Type-check + run: npm run typecheck + + - name: Unit tests with coverage + run: npx vitest run --coverage + + - name: Production build + run: npm run build + + - name: Bundle-size budget + run: npm run check:size + + - name: Upload bundle (PR builds only) + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: web-dist + path: web/dist + retention-days: 7 diff --git a/.gitignore b/.gitignore index 7727610..6154873 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ htmlcov/ # --- Build artifacts ------------------------------------------------- # dist/app.py is the single-file ship target — tracked, regenerated by # scripts/build_single_file.py. +# dist/airgap/ is the composed v2.0 air-gap deploy folder produced by +# scripts/package_airgap.py — local-only, never committed. +dist/airgap/ # --- Runtime data (user-specific, not source) ------------------------ # SQLite metadata DB and FAISS vector index live under /tmp by default @@ -76,6 +79,8 @@ docs/* !docs/09-build-deploy-release.md !docs/10-known-risks-and-todos.md !docs/11-agent-handoff.md +!docs/RELEASE.md +!docs/REACT_UI_PARITY.md !docs/adr/ !docs/adr/*.md REVIEW_*.md diff --git a/README.md b/README.md index 0bb84cd..463c430 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,17 @@ uv run pytest tests/ -x # Run the incident-management app via the CLI entrypoint uv run python -m runtime --config config/incident_management.yaml -# Streamlit UI +# Backend API (serves the React SPA at / when web/dist exists) +uv run uvicorn runtime.api:get_app --factory --port 8000 + +# React UI (Vite dev server; proxy /api/v1 -> :8000) +cd web && npm ci && npm run dev # http://localhost:5173 + +# Production: build once, then the backend serves it at http://localhost:8000 +cd web && npm run build && cd .. +uv run uvicorn runtime.api:get_app --factory --port 8000 + +# Legacy Streamlit UI (deprecated in v2; banner inside) ASR_LOG_LEVEL=INFO uv run streamlit run src/runtime/ui.py --server.port 37777 ``` @@ -50,10 +60,16 @@ Set provider keys in `.env` (`OLLAMA_API_KEY`, `OPENROUTER_API_KEY`, contributor loop: setup, regenerating `dist/`, adding a runtime module. - **[`docs/AIRGAP_INSTALL.md`](docs/AIRGAP_INSTALL.md)** — - air-gapped / internal-mirror install procedure. + air-gapped / internal-mirror install procedure (includes the v2 + React UI air-gap layout). +- **[`docs/RELEASE.md`](docs/RELEASE.md)** — cutting a release + candidate; `npm run build` + `scripts/package_airgap.py` + git tag. ## Status -`main` carries v1.0 → v1.5. v2.0 (React UI replacing the Streamlit -prototype) is the next big move. See `docs/DESIGN.md` § 13 for the -milestone history and § 14 for the pending list. +`main` carries v1.0 → v1.5. **v2.0.0-rc1** ships the React UI +(Vite + React 19 + TS + Tailwind v4 + Radix primitives) in `web/` +which replaces the Streamlit prototype. The legacy Streamlit UI +remains shippable behind a deprecation banner until parity is +verified (`docs/REACT_UI_PARITY.md`). See `docs/DESIGN.md` § 13 +for the milestone history. diff --git a/docs/AIRGAP_INSTALL.md b/docs/AIRGAP_INSTALL.md index 2473b20..ebc0fa5 100644 --- a/docs/AIRGAP_INSTALL.md +++ b/docs/AIRGAP_INSTALL.md @@ -51,3 +51,38 @@ uv lock --check # exit 0 = in sync; non-zero = regenerate with `uv lock` installs on any host (HARD-02 / CONCERNS C2). - Ship vendored wheels as a separate tarball if your host has no mirror at all; populate `~/.cache/uv` (or `UV_CACHE_DIR`) before running step 3. + +## v2.0 — React UI in the air-gap payload + +The React SPA in `web/` is built ahead of time and shipped as static +assets alongside the Python bundle. The backend serves it from `/` +via `runtime.api_static.mount_static_assets` so there is no separate +Node process on the deploy host. + +```bash +# On a host with internet (one time, or each release): +cd web && npm ci && npm run build +cd .. +uv run python scripts/build_single_file.py # framework + app bundles +uv run python scripts/package_airgap.py # composes dist/airgap/ + +# Ship dist/airgap/ to the air-gap host. Layout: +# dist/airgap/ +# app.py # flattened framework + incident-management +# ui.py # legacy Streamlit (deprecation banner) +# web/ # built React SPA (Vite output) +# README.txt +``` + +On the air-gap host: + +```bash +ASR_WEB_DIST=./web \ + python -m uvicorn app:get_app --factory --port 8000 +# open http://:8000/ +``` + +`ASR_WEB_DIST` is read by `runtime.api_static.mount_static_assets`. +If unset, the backend falls back to `/web/dist` (legacy dev +layout). When neither resolves, the SPA mount is skipped silently and +the API still serves on `/api/v1/*`. diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..49c71de --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,63 @@ +# Release process — v2.0 + +The React UI lives at `web/`. The Python framework lives at `src/runtime/`. +A v2.0.0-rc1 release ships **both** as a single tag. + +## Cut a release candidate + +1. Verify `web/package.json` version matches the tag you intend to cut. + ```bash + jq -r .version web/package.json + ``` + For v2.0.0-rc1 the answer should be `2.0.0-rc1`. + +2. Regenerate the framework bundles (HARD-08 requirement): + ```bash + uv run python scripts/build_single_file.py + git diff --exit-code dist/ # must be clean + ``` + +3. Build the React SPA: + ```bash + cd web && npm ci && npm run build + ``` + +4. Compose the air-gap deploy payload: + ```bash + uv run python scripts/package_airgap.py --out dist/airgap + ``` + Output: `dist/airgap/{app.py, ui.py, web/, README.txt}` — local-only, + never committed. Drop it on the deploy host. + +5. Sanity-check the bundle gates locally: + ```bash + uv run pytest -x + uv run ruff check src/ tests/ + uv run pyright src/runtime + (cd web && npm run lint && npm run typecheck && npm run test:unit && npm run build && npm run check:size) + ``` + +6. Tag the release. Tagging always happens on `main` after the final PR + merge, never on a feature branch: + ```bash + git switch main + git pull --ff-only + git tag v2.0.0-rc1 -m "v2.0.0-rc1: React UI" + git push origin v2.0.0-rc1 + ``` + +## Bump for the next RC + +When cutting `v2.0.0-rc2` (or `v2.0.0`): + +1. `web/package.json` — update the `version` field. +2. Repeat the build + tag steps above. + +Avoid editing `package.json` mid-PR for an unrelated change; the version +field changes only in a release-prep PR. + +## Streamlit prototype + +`dist/ui.py` (legacy Streamlit) ships in the deploy folder until the +React UI hits 100% parity (see `docs/REACT_UI_PARITY.md`). Streamlit +displays a deprecation banner; the React UI is the supported path. diff --git a/scripts/package_airgap.py b/scripts/package_airgap.py new file mode 100644 index 0000000..cc00f41 --- /dev/null +++ b/scripts/package_airgap.py @@ -0,0 +1,121 @@ +"""Package the air-gap deploy payload. + +Composes the 'copy-this-folder' release artifact for the v2.0 deploy +shape. Output layout (mirrors :func:`runtime.api_static.mount_static_assets` +defaults so the deployed FastAPI process can find the SPA without env-var +gymnastics): + +:: + + out/ + app.py # symlink/copy of dist/apps/incident-management.py + ui.py # dist/ui.py (legacy Streamlit; deprecation banner in v2) + web/ # web/dist/* — built React SPA (Vite output) + index.html + assets/... + fonts/... + +Deploy contract: + +- Backend reads ``ASR_WEB_DIST`` (set to ``./web`` by the launcher) or + falls back to ``/web/dist`` (legacy dev layout). Either resolves + to the SPA produced by ``cd web && npm run build``. +- The five YAML configs + ``.env`` ship alongside the bundle out of band. + +Run: + +:: + + cd web && npm ci && npm run build + uv run python scripts/build_single_file.py # framework + app .py bundles + uv run python scripts/package_airgap.py # composes out/ + +The output directory is overwritten on each run (no incremental merge). +""" +from __future__ import annotations +import argparse +import shutil +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +DIST_DIR = REPO_ROOT / "dist" +APP_BUNDLE = DIST_DIR / "apps" / "incident-management.py" +UI_BUNDLE = DIST_DIR / "ui.py" +WEB_BUILT = REPO_ROOT / "web" / "dist" + + +def package(out_dir: Path, *, strict: bool) -> int: + """Compose the air-gap payload at ``out_dir``. Returns process-exit code.""" + missing: list[str] = [] + if not APP_BUNDLE.is_file(): + missing.append(f" - {APP_BUNDLE.relative_to(REPO_ROOT)} (run scripts/build_single_file.py first)") + if not UI_BUNDLE.is_file(): + missing.append(f" - {UI_BUNDLE.relative_to(REPO_ROOT)} (run scripts/build_single_file.py first)") + if not (WEB_BUILT / "index.html").is_file(): + missing.append( + f" - {WEB_BUILT.relative_to(REPO_ROOT)}/index.html" + " (run: cd web && npm ci && npm run build)" + ) + if missing: + print("✗ air-gap inputs missing:", file=sys.stderr) + for m in missing: + print(m, file=sys.stderr) + if strict: + return 2 + + if out_dir.exists(): + shutil.rmtree(out_dir) + out_dir.mkdir(parents=True) + + if APP_BUNDLE.is_file(): + shutil.copy2(APP_BUNDLE, out_dir / "app.py") + if UI_BUNDLE.is_file(): + shutil.copy2(UI_BUNDLE, out_dir / "ui.py") + if (WEB_BUILT / "index.html").is_file(): + shutil.copytree(WEB_BUILT, out_dir / "web") + + (out_dir / "README.txt").write_text( + "ASR v2.0 air-gap deploy payload\n" + "-------------------------------\n" + "Contents:\n" + " app.py — flattened framework + incident-management app\n" + " ui.py — legacy Streamlit prototype (deprecated in v2)\n" + " web/ — built React SPA, served by uvicorn at /\n\n" + "Run:\n" + " ASR_WEB_DIST=./web uv run uvicorn app:get_app --factory --port 8000\n" + "or with the host's pinned Python:\n" + " ASR_WEB_DIST=./web python -m uvicorn app:get_app --factory --port 8000\n\n" + "Open: http://:8000/\n" + ) + + print(f"✓ wrote {out_dir}") + for p in sorted(out_dir.rglob("*")): + if p.is_file(): + try: + size = p.stat().st_size + print(f" {p.relative_to(out_dir).as_posix():<40} {size:>10,} b") + except OSError: + pass + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Package the v2.0 air-gap deploy folder.") + parser.add_argument( + "--out", + default="dist/airgap", + help="Output directory (default: dist/airgap). Wiped each run.", + ) + parser.add_argument( + "--allow-partial", + action="store_true", + help="Emit whatever inputs are present instead of failing on missing pieces.", + ) + args = parser.parse_args() + out_dir = Path(args.out).resolve() + return package(out_dir, strict=not args.allow_partial) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..8b0072f --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,41 @@ +// web/eslint.config.js +import js from '@eslint/js'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; + +export default tseslint.config( + { ignores: ['dist', 'node_modules', 'coverage', 'playwright-report', 'test-results'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2022, + globals: { ...globals.browser, ...globals.node }, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + }, + }, + { + files: ['tests/**/*.{ts,tsx}'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + 'react-refresh/only-export-components': 'off', + }, + }, +); diff --git a/web/package-lock.json b/web/package-lock.json index 70d0cd8..d6ed0ca 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -19,6 +19,7 @@ }, "devDependencies": { "@axe-core/playwright": "^4.10.0", + "@eslint/js": "^9.39.4", "@playwright/test": "^1.49.0", "@tailwindcss/vite": "^4.0.0", "@testing-library/jest-dom": "^6.6.0", @@ -31,10 +32,14 @@ "@vitejs/plugin-react": "^4.3.0", "@vitest/ui": "^3.0.0", "eslint": "^9.0.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.26", + "globals": "^15.15.0", "jsdom": "^25.0.0", "prettier": "^3.4.0", "tailwindcss": "^4.0.0", "typescript": "^5.7.0", + "typescript-eslint": "^8.59.3", "vite": "^7.1.0", "vitest": "^3.0.0" } @@ -1092,6 +1097,19 @@ "concat-map": "0.0.1" } }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@eslint/eslintrc/node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4019,6 +4037,29 @@ } } }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -4411,9 +4452,9 @@ } }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", "dev": true, "license": "MIT", "engines": { @@ -6034,6 +6075,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", + "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.3", + "@typescript-eslint/parser": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", diff --git a/web/package.json b/web/package.json index c66cb83..4725605 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,8 @@ "lint": "eslint .", "typecheck": "tsc -b", "test:unit": "vitest run", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "check:size": "node scripts/check-size.mjs" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.0", @@ -24,6 +25,7 @@ }, "devDependencies": { "@axe-core/playwright": "^4.10.0", + "@eslint/js": "^9.39.4", "@playwright/test": "^1.49.0", "@tailwindcss/vite": "^4.0.0", "@testing-library/jest-dom": "^6.6.0", @@ -36,10 +38,14 @@ "@vitejs/plugin-react": "^4.3.0", "@vitest/ui": "^3.0.0", "eslint": "^9.0.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.26", + "globals": "^15.15.0", "jsdom": "^25.0.0", "prettier": "^3.4.0", "tailwindcss": "^4.0.0", "typescript": "^5.7.0", + "typescript-eslint": "^8.59.3", "vite": "^7.1.0", "vitest": "^3.0.0" } diff --git a/web/scripts/check-size.mjs b/web/scripts/check-size.mjs new file mode 100644 index 0000000..24de004 --- /dev/null +++ b/web/scripts/check-size.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node +// web/scripts/check-size.mjs +// Hard gate for the production JS bundle. Run after `vite build`. +// +// Budgets are intentionally conservative — they sit ~25% above the +// current production size so we get warned before drift turns into a +// regression. Move them down deliberately when the bundle shrinks, +// up only with a written rationale on the commit. + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { gzipSync } from 'node:zlib'; +import { join } from 'node:path'; + +const DIST_ASSETS = 'dist/assets'; +const BUDGETS = { + rawBytes: 400 * 1024, // 400 kB raw + gzipBytes: 130 * 1024, // 130 kB gzip +}; + +function findBundle() { + const entries = readdirSync(DIST_ASSETS); + const candidates = entries.filter((f) => /^index-.*\.js$/.test(f)); + if (candidates.length === 0) { + console.error(`✗ no index-*.js bundle found in ${DIST_ASSETS}/`); + process.exit(2); + } + // Largest wins (defensive: vite shouldn't emit multiple index-* but + // chunk splits could introduce variants). + return candidates + .map((f) => ({ name: f, path: join(DIST_ASSETS, f), size: statSync(join(DIST_ASSETS, f)).size })) + .sort((a, b) => b.size - a.size)[0]; +} + +function format(n) { + return `${(n / 1024).toFixed(2)} kB`; +} + +const bundle = findBundle(); +const raw = bundle.size; +const gzip = gzipSync(readFileSync(bundle.path)).length; + +console.log(`bundle: ${bundle.name}`); +console.log(` raw: ${format(raw)} / budget ${format(BUDGETS.rawBytes)}`); +console.log(` gzip: ${format(gzip)} / budget ${format(BUDGETS.gzipBytes)}`); + +let fail = false; +if (raw > BUDGETS.rawBytes) { + console.error(`✗ raw size exceeds budget by ${format(raw - BUDGETS.rawBytes)}`); + fail = true; +} +if (gzip > BUDGETS.gzipBytes) { + console.error(`✗ gzip size exceeds budget by ${format(gzip - BUDGETS.gzipBytes)}`); + fail = true; +} + +if (fail) process.exit(1); +console.log('✓ bundle within budget');