Files
HiveGrid-Wireframes/hivegrid-cloud/docs/architecture/lighthouse.md
T

8.4 KiB

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.

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:

# 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:

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.<metric> with tags:

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).