# Lighthouse-CI audit and Web-Vitals reporting This document describes how the Lighthouse-CI audit is wired into the Toolctrl Boilerplate, how to run it locally when debugging a regression, and how the Web-Vitals → Sentry bridge surfaces real-user metrics. Spec: `docs/improvement-plan/10-compliance-polish/lighthouse.md` (Task #70). Companion artefacts: `.lighthouserc.staging.json`, `.lighthouserc.json`, `scripts/lighthouse-puppeteer-auth.cjs`, `scripts/rotate-lighthouse-session-cookie.mjs`, `.github/workflows/lighthouse.yml`, `.gitea/workflows/lighthouse.yml`, `apps/web/app/lib/observability/web-vitals.ts`, `apps/web/app/lib/pwa/registerServiceWorker.ts`, `apps/web/public/manifest.webmanifest`, `apps/web/public/sw.js`. ## Where the audit runs A meaningful Lighthouse audit needs a live server, a database and seeded content. Reproducing that whole stack inside a CI job is brittle, so the CI workflow audits the **deployed staging site** (`https://boiler.saas-ctrl.com`) rather than a CI-local server. That makes it a **post-deploy / monitoring audit, not a per-commit merge gate** — staging always lags the commit under review. The workflow (`.gitea/workflows/lighthouse.yml`, mirrored in `.github/`) runs: - **nightly** at 03:00 UTC (`schedule`), and - **on demand** (`workflow_dispatch`). A red run means the deployed staging site fell below a threshold — treat it as a production-quality alarm, not a blocked PR. The Gitea runner image (`node:20-bookworm`) ships no browser, so the workflow installs `chromium` via `apt-get` and points LHCI at it with `CHROME_PATH=/usr/bin/chromium`. The GitHub runner (`ubuntu-latest`) already bundles Chrome. ## Audit targets `.lighthouserc.staging.json` audits the public staging routes — the surface that renders without authentication: | URL | | -------------------------------------------- | | `https://boiler.saas-ctrl.com/login` | | `https://boiler.saas-ctrl.com/privacy-policy` | | `https://boiler.saas-ctrl.com/terms-and-conditions` | Only **Performance** is a hard gate (`error`). The product is a B2B SaaS, not a search-discoverable marketing site, so the SEO category is not asserted at all. Accessibility and Best-Practices stay visible as `warn` — accessibility is already hard-gated separately by `a11y.yml` (axe-core plus the `jsx-a11y` ESLint baseline, task #73), so a second blocking gate here would be redundant. | Category | Threshold | Severity | | --------------- | --------- | ------------------------- | | Performance | 0.95 | error | | Accessibility | 0.95 | warn | | Best Practices | 0.95 | warn | | PWA | 0.90 | warn | | SEO | — | not asserted (irrelevant) | Numeric audits (FCP / LCP / TBT / CLS) stay at `warn` severity so trend regressions remain visible without failing the run. ## Session cookie rotation The workflow mints a fresh staging session before every LHCI run via `scripts/rotate-lighthouse-session-cookie.mjs`. The script logs in as the dedicated `lighthouse-bot` account, extracts the CSRF token from `/login`, posts the login form with the CSRF header, then appends only the resulting session cookie to the Actions environment. It never prints the cookie or password. Required workflow secrets: | Secret | Purpose | | ------ | ------- | | `LIGHTHOUSE_LOGIN_EMAIL` | Email of the staging Lighthouse bot user. | | `LIGHTHOUSE_LOGIN_PASSWORD` | Password of the staging Lighthouse bot user. | The session cookie name is `RJ_session`; workflows also pass `LIGHTHOUSE_SESSION_COOKIE_NAME=RJ_session` and `LIGHTHOUSE_COOKIE_DOMAIN=boiler.saas-ctrl.com`. During rollout the workflow still forwards the older `LIGHTHOUSE_SESSION_COOKIE` secret as a fallback. If the login email or password secret is absent, the script reuses that pre-provisioned cookie without printing it. Permanent operation should use the login secrets so each run mints a fresh cookie. ## Authenticated routes Auditing `/app/$tenant/*` and `/admin/*` needs that freshly minted `RJ_session` cookie. `scripts/lighthouse-puppeteer-auth.cjs` injects the cookie for protected URLs and is already wired into `.lighthouserc.staging.json`. The current staging target list still audits only public routes; add protected URLs once the exact tenant/admin coverage list is chosen. Without a protected URL in the config, the cookie step is a readiness/rotation guard rather than an auth coverage expansion. ## Running Lighthouse locally `.lighthouserc.json` is the local-iteration config (URLs on `http://localhost:3000`); `pnpm verify:lighthouse` runs it via `scripts/run-lighthouse.mjs`. Bring the app up first: ```sh # Build apps/web (writes hashed assets the runtime SW expects to cache) pnpm --filter @toolctrl/web build # Serve the build at http://localhost:3000. pnpm --filter @toolctrl/web start & # Run the full LHCI flow against the local server pnpm verify:lighthouse ``` To reproduce a CI staging finding locally, point LHCI at the staging config instead — no local server needed, just a Chrome install: ```sh pnpm dlx @lhci/cli@0.13.x autorun --config=./.lighthouserc.staging.json ``` The run prints the temporary-public-storage report URLs in its output; share those when debugging a regression. ## PWA manifest + service-worker `apps/web/public/manifest.webmanifest` describes the installable app. `apps/web/public/sw.js` is a minimal cache-first / network-first service worker registered from `root.tsx` via `registerServiceWorker`. Cache strategy: - `/assets/...` (hashed by React-Router build) → **cache-first** - `/api/...`, `/app/...`, `/admin/...` → **network-first** with cache fallback - everything else (marketing routes, brand assets) → **network-first** The `CACHE_VERSION` constant inside `sw.js` is the invalidation lever — bump it on any deploy that changes the asset graph in a backward- incompatible way. Old caches are dropped automatically in the activate handler. To debug a stuck SW, open DevTools → Application → Service Workers and click "Unregister". The next page load picks up the new `sw.js`. ## Web-Vitals → Sentry `apps/web/app/lib/observability/web-vitals.ts` registers callbacks for CLS, LCP, INP, TTFB, and FCP via the `web-vitals` library. Each metric is captured as a Sentry message named `web-vitals.` with tags: ```text web.vital.name = CLS | LCP | INP | TTFB | FCP web.vital.rating = good | needs-improvement | poor ``` In the Sentry dashboard: 1. Filter to `message:"web-vitals.LCP"` to view a metric in isolation. 2. Group by `tags.web.vital.rating` to see the share of "poor" loads. 3. Group by `transaction` (Sentry React-Router integration) to compare metrics across routes. The helper is SSR-safe (bails on `typeof window === "undefined"`) and silently no-ops when Sentry isn't initialised, so the same code runs in dev, preview, staging, and production without extra gating. ## Troubleshooting CI runs | Symptom | Likely cause / fix | | -------------------------------------------------- | ----------------------------------------------------------------------------- | | `Chrome installation not found` / `Healthcheck failed!` | The runner has no browser. The Gitea workflow installs `chromium`; confirm the install step ran and `CHROME_PATH` is set. | | Cookie rotation step fails with missing env vars | Add `LIGHTHOUSE_LOGIN_EMAIL` and `LIGHTHOUSE_LOGIN_PASSWORD` as repo Actions secrets. | | Audit reports the `/login` page for a protected URL | Cookie injection failed or the bot no longer belongs to the tenant/admin surface being audited. | | Performance score < 0.95 only on first run | Cold cache; LHCI reruns automatically (`numberOfRuns=3`). | | Audit times out | Lower `numberOfRuns` to 1 temporarily while iterating; restore afterwards. | | All URLs fail to load | Staging is down or redeploying — check the `web` container on staging. | ## Updating thresholds Permanent relaxation requires a `.lighthouserc.staging.json` commit plus a paragraph in this document explaining the trade-off. Prefer switching a category from `error` to `warn` over dropping it, so regressions stay visible — SEO is the one deliberate exception (not relevant to a B2B SaaS, so not asserted at all).