Skip to content

Commit 0ed63f6

Browse files
style: lighthouse test for landing
1 parent 35a6848 commit 0ed63f6

6 files changed

Lines changed: 1039 additions & 39 deletions

File tree

.github/workflows/lighthouse.yml

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
name: Lighthouse
2+
3+
# Only when the demo site changes. It is a separate workflow rather than a job
4+
# in tests.yml because the two ask different questions: that one is the
5+
# library's correctness coverage across every example app and three browsers,
6+
# this one measures the single app that state-in-url.dev serves. Keeping it
7+
# apart means a change to `packages/urlstate` pays for neither the demo build
8+
# nor the ~100 MB `lighthouse` dependency.
9+
#
10+
# `packages/urlstate` is deliberately *not* a trigger path even though the demo
11+
# imports it: a library change that alters the demo's bundle is caught by the
12+
# next demo change or by a manual run. Auditing on every library commit would
13+
# put a timing measurement on the critical path of the package's own releases.
14+
on:
15+
push:
16+
branches: [ master ]
17+
paths:
18+
- 'packages/example-nextjs16/**'
19+
- 'lighthouse/**'
20+
- 'playwright.lighthouse.config.ts'
21+
- '.github/workflows/lighthouse.yml'
22+
pull_request:
23+
branches: [ master ]
24+
paths:
25+
- 'packages/example-nextjs16/**'
26+
- 'lighthouse/**'
27+
- 'playwright.lighthouse.config.ts'
28+
- '.github/workflows/lighthouse.yml'
29+
workflow_dispatch:
30+
31+
concurrency:
32+
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
33+
cancel-in-progress: true
34+
35+
permissions:
36+
contents: read
37+
38+
jobs:
39+
lighthouse:
40+
name: lighthouse
41+
timeout-minutes: 20
42+
runs-on: ubuntu-24.04
43+
permissions:
44+
contents: read
45+
steps:
46+
- name: Harden Runner
47+
uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4
48+
with:
49+
egress-policy: audit
50+
51+
# Actions are pinned to a commit SHA with the tag in the trailing comment,
52+
# as everywhere else in this repo. `pnpm run pin-gh-deps` covers this file.
53+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
54+
with:
55+
persist-credentials: false
56+
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
57+
with:
58+
version: 10.26.0
59+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
60+
with:
61+
node-version-file: '.nvmrc'
62+
cache: 'pnpm'
63+
64+
# No playwright container here, unlike the e2e job in tests.yml: that one
65+
# needs three browser engines, Lighthouse only ever drives chromium.
66+
- name: Install dependencies
67+
run: pnpm install --frozen-lockfile && pnpm exec playwright install --with-deps chromium
68+
69+
# Builds the library, then the demo, then audits the three public pages.
70+
# Both builds are part of the suite's webServer block, so there is no
71+
# separate build step here.
72+
#
73+
# Performance is a timing measurement and this runner is shared, which is
74+
# why that threshold sits at 95 rather than at the 99 the pages measure —
75+
# see the note on THRESHOLDS in lighthouse/lighthouse.spec.ts. If it flakes
76+
# anyway, lower that one number rather than dropping the assertion; the
77+
# other three categories are deterministic and are why this job exists.
78+
- name: Lighthouse audits
79+
run: pnpm run test:lighthouse
80+
81+
# Playwright leaves an error context per failure. For a score that only
82+
# drops on the runner, that is the only record of which audit gave way.
83+
- name: Upload Lighthouse results
84+
if: failure()
85+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
86+
with:
87+
name: lighthouse-results
88+
path: test-results/
89+
retention-days: 7
90+
if-no-files-found: ignore

CLAUDE.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ This is a **pnpm** monorepo. `pnpm` is enforced (`only-allow`). Most scripts run
1919
- `pnpm run kill` — kill hung Next/Vite/wireit processes (use this if dev/test servers hang).
2020
- `pnpm run cleanup` / `pnpm run reinstall` — nuke build artifacts and node_modules.
2121
- `pnpm run setup``playwright install --with-deps` (needed before e2e on a fresh machine).
22+
- `pnpm run test:lighthouse` — Lighthouse audits of the demo site (see below). Builds and serves it itself; nothing needs to be running first.
2223

2324
Run a single unit test: `npx vitest run packages/urlstate/encoder/encoder.test.ts` (or pass a `-t "name"` filter).
2425
Run a single e2e spec: `npx playwright test tests/useUrlState/main.spec.ts --project=chromium`.
@@ -29,6 +30,7 @@ Run a single e2e spec: `npx playwright test tests/useUrlState/main.spec.ts --pro
2930
- `packages/example-*` — the workspace members: demo apps for nextjs14/15/16, react (Vite), react-router6, react-router7, remix2. These exist to be driven by the Playwright e2e tests and to host the live demo.
3031
- `packages/shared/` — shared Tailwind config, styles, and components used by the example apps (aliased as `shared/*` in `tsconfig.base.json`).
3132
- `tests/` — Playwright e2e specs (separate from the colocated `*.test.ts` unit tests).
33+
- `lighthouse/` — the Lighthouse suite, its own directory so the e2e config's `testDir: './tests'` cannot pick it up. Driven by `playwright.lighthouse.config.ts`.
3234
- `skills/` — agent skill files (`SKILL.md` per topic) that are **published as part of the npm package** (see `files` in `package.json`). `skills/_artifacts/` is dev-only and excluded from publish; `skill_spec.md` there is a useful map of the library's domains and known user failure modes.
3335

3436
## Architecture
@@ -53,9 +55,36 @@ The library is layered; each layer has its own subdirectory under `packages/urls
5355
- Only JSON-serializable values; functions/symbols are dropped.
5456
- In Next.js App Router, pass `searchParams` for SSR correctness; in Next 15+, `await` them first.
5557

58+
## Lighthouse (demo site)
59+
60+
`pnpm run test:lighthouse``lighthouse/lighthouse.spec.ts`, driven by
61+
`playwright.lighthouse.config.ts`. `.github/workflows/lighthouse.yml` runs it on
62+
changes under `packages/example-nextjs16/**` and nowhere else.
63+
64+
The subject is **`packages/example-nextjs16`** — the app https://state-in-url.dev
65+
actually serves, and the only example with public pages (`example-nextjs15` has
66+
nothing but the `(tests)` group). It audits the production build: `build:demo`
67+
(which rebuilds the library first, so the demo is never measured against a stale
68+
`dist/`) then `next start --port 3012`. Port 3012, not the demo's own 3002, so a
69+
server left over from `pnpm run dev` cannot be silently accepted in place of it.
70+
71+
**Only the three public pages**`/`, `/react-router`, `/remix`. The app also
72+
serves `/useUrlState`, `/test-ssr` and the rest of the `(tests)` group, but those
73+
are e2e fixtures that happen to be deployed: they exist to be asserted against,
74+
not read, and several render in ways no real page would.
75+
76+
**Performance is held at 95, not 100, and that is deliberate.** Measured August
77+
2026, all three pages sit at **99** across repeated runs, and the shortfall is
78+
entirely one metric — LCP ~1030 ms scoring 0.94 of a weight-25 audit. Everything
79+
else is perfect (FCP ~290 ms, total blocking time 6-13 ms). 95 leaves ~4 points
80+
for runner variance while still failing on a real regression. Total blocking time
81+
being near zero is what makes that safe: almost no main-thread work is in play, so
82+
a slower CI runner moves this far less than it would for a hydration-heavy app.
83+
Accessibility, best-practices and SEO are deterministic and held at 100.
84+
5685
## Conventions
5786

5887
- **Conventional commits** (commitlint + commitizen enforced via husky). Releases are automated by semantic-release. `fix:` and `feat:` bump the version and **must only be used for changes inside `packages/urlstate/` or `package.json`** — use `ci`, `build`, `docs`, `style`, `test`, `chore`, `refactor` for everything else (examples, tests, config).
5988
- ESLint config (`.eslintrc.cjs`) uses `plugin:maintainable/recommended` with a complexity cap of 12 and enforced import sorting; Prettier runs via lint-staged on commit. Unused vars must be prefixed `_`.
60-
- Node 20 (`.nvmrc`). Build target ES2022.
89+
- Node 24 (`.nvmrc`). Build target ES2022.
6190
- When adding a new entry point, add it to the `exports` map in `package.json` and ensure its `index.ts` is picked up by the Rollup glob (`packages/urlstate/**/index.ts`).

lighthouse/lighthouse.spec.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* Lighthouse audits of the demo site, against the production build.
3+
*
4+
* See THRESHOLDS below for why performance is the one category not held at 100.
5+
*/
6+
import { chromium, test } from '@playwright/test';
7+
import { playAudit } from 'playwright-lighthouse';
8+
import desktopConfig from 'lighthouse/core/config/desktop-config.js';
9+
10+
import { PREVIEW_URL } from '../playwright.lighthouse.config';
11+
12+
/**
13+
* The public pages only — what a visitor to state-in-url.dev actually lands on.
14+
*
15+
* The app also serves `/useUrlState`, `/test-ssr`, `/useSharedState` and the
16+
* rest of the `(tests)` route group. Those are fixtures for the Playwright e2e
17+
* suite that happen to be deployed with everything else: they exist to be
18+
* driven by assertions, not read, and several deliberately render in ways no
19+
* real page would. Auditing them would hold the library's test harness to a
20+
* marketing page's standards and produce failures nobody should act on.
21+
*/
22+
const PAGES = [
23+
{ path: '/', name: '/ (useUrlState demo)' },
24+
{ path: '/react-router', name: '/react-router' },
25+
{ path: '/remix', name: '/remix' },
26+
];
27+
28+
/**
29+
* Accessibility, best-practices and SEO are deterministic audits of the
30+
* document and every page scores 100, so they are held there.
31+
*
32+
* Performance is not 100 and setting it there would be wishful. Measured
33+
* August 2026 on all three pages: **99**, held across repeated runs, and the
34+
* shortfall is entirely one metric — LCP at ~1030 ms, scoring 0.94 of a
35+
* weight-25 audit. Everything else is perfect (FCP ~290 ms, total blocking
36+
* time 6-13 ms, speed index ~400 ms).
37+
*
38+
* 95 is therefore a real bar rather than a rubber stamp: it is ~4 points below
39+
* what the pages reliably measure, which absorbs runner variance while still
40+
* failing on anything that actually regresses — an unoptimised image, a
41+
* client bundle that grows, a blocking third-party script. Because total
42+
* blocking time is near zero, almost no main-thread work is in play, so a
43+
* slower CI runner moves this number far less than it would for a
44+
* hydration-heavy app.
45+
*
46+
* If it starts flaking anyway, lower this one number rather than deleting the
47+
* assertion. The real performance signal is field data and PageSpeed Insights
48+
* against production.
49+
*/
50+
const THRESHOLDS = {
51+
performance: 95,
52+
accessibility: 100,
53+
'best-practices': 100,
54+
seo: 100,
55+
};
56+
57+
test.describe('Lighthouse', () => {
58+
// Serial: two Chrome instances auditing at once skew each other's
59+
// performance numbers, and there is nothing to gain by racing three runs.
60+
test.describe.configure({ mode: 'serial' });
61+
62+
for (const { path, name } of PAGES) {
63+
// No fixture parameter: this test drives its own browser, and Playwright
64+
// rejects a named first argument ("First argument must use the object
65+
// destructuring pattern"), leaving `{}` as the only spelling — which is
66+
// itself a lint error. `test.info()` is the way out of both.
67+
test(`${name} meets Lighthouse thresholds`, async () => {
68+
// Lighthouse drives the browser over CDP, which needs a debugging port
69+
// Playwright's own `page` fixture does not expose. Offset by worker index
70+
// so a future parallel run cannot collide on it.
71+
const port = 9333 + test.info().workerIndex;
72+
const browser = await chromium.launch({
73+
args: [`--remote-debugging-port=${port}`],
74+
});
75+
76+
try {
77+
const page = await browser.newPage();
78+
await page.goto(`${PREVIEW_URL}${path}`, { waitUntil: 'networkidle' });
79+
80+
await playAudit({
81+
page,
82+
port,
83+
thresholds: THRESHOLDS,
84+
// Desktop, not Lighthouse's mobile default: mobile applies a 4x CPU
85+
// slowdown, which turns the performance score into a measurement of
86+
// the runner rather than the site.
87+
config: desktopConfig,
88+
disableLogs: false,
89+
});
90+
} finally {
91+
await browser.close();
92+
}
93+
});
94+
}
95+
});

package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@
106106
"build": "wireit",
107107
"build:demo": "wireit",
108108
"start:demo": "wireit",
109+
"lighthouse:serve": "cd packages/example-nextjs16 && pnpm exec next start --port 3012",
110+
"test:lighthouse": "playwright test -c playwright.lighthouse.config.ts",
109111
"build:packages": "wireit",
110112
"start:demo15": "wireit",
111113
"start:demo15:ci": "wireit",
@@ -125,7 +127,7 @@
125127
"reinstall": "pnpm run cleanup && pnpm install",
126128
"setup": "playwright install --with-deps",
127129
"prepack": "pnpm run build && cp package.json dist/package.json",
128-
"pin-gh-deps": "pin-github-action -i .github/workflows/tests.yml",
130+
"pin-gh-deps": "pin-github-action -i .github/workflows/tests.yml .github/workflows/lighthouse.yml",
129131
"prepare": "pnpm exec husky"
130132
},
131133
"wireit": {
@@ -472,9 +474,9 @@
472474
"@rollup/plugin-typescript": "^12.1.4",
473475
"@semantic-release/changelog": "^6.0.3",
474476
"@semantic-release/commit-analyzer": "^13.0.1",
477+
"@semantic-release/exec": "^7.1.0",
475478
"@semantic-release/git": "^10.0.1",
476479
"@semantic-release/npm": "^13.1.3",
477-
"@semantic-release/exec": "^7.1.0",
478480
"@semantic-release/release-notes-generator": "^14.1.0",
479481
"@tanstack/intent": "^0.3.6",
480482
"@testing-library/dom": "^10.4.1",
@@ -504,12 +506,14 @@
504506
"glob": "^13.0.0",
505507
"happy-dom": "^20.0.2",
506508
"husky": "^9.1.7",
509+
"lighthouse": "^13.4.1",
507510
"lint-staged": "^15.5.2",
508511
"next": "^15.5.19",
509512
"nyc": "^17.1.0",
510513
"only-allow": "^1.2.1",
511514
"pin-github-action": "^1.9.1",
512515
"playwright": "^1.55.1",
516+
"playwright-lighthouse": "^4.0.0",
513517
"prettier": "^3.6.2",
514518
"prettier-plugin-tailwindcss": "^0.6.14",
515519
"react": "^19.2.6",

playwright.lighthouse.config.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* Lighthouse guardrails for the demo site.
3+
*
4+
* Separate from `playwright.config.ts` on purpose. That suite is the library's
5+
* e2e coverage: it drives every example app across chromium/firefox/webkit and
6+
* asserts that URL state survives navigation. This one asks a different
7+
* question about a single app — `packages/example-nextjs16`, which is what
8+
* https://state-in-url.dev serves — and only about the pages a visitor sees.
9+
*
10+
* It audits the production build (`next build` + `next start`), never
11+
* `next dev`. The two differ in most of what is being scored: minified bundles,
12+
* no dev overlay, no HMR client, real prerendering.
13+
*/
14+
import { defineConfig } from '@playwright/test';
15+
16+
/**
17+
* Deliberately not 3002, which the demo's own `dev` and `start` scripts use: a
18+
* server left running from `pnpm run dev` would otherwise be silently accepted
19+
* in place of the build, and dev is the one thing this suite must not measure.
20+
*/
21+
const PREVIEW_PORT = 3012;
22+
23+
/** Exported for the spec, which drives its own browser over CDP and so never
24+
* sees `baseURL`. */
25+
export const PREVIEW_URL = `http://localhost:${PREVIEW_PORT}`;
26+
27+
export default defineConfig({
28+
testDir: './lighthouse',
29+
// Serial, one worker. Two Chrome instances auditing at once skew each other's
30+
// performance numbers — the audit ends up measuring the test runner rather
31+
// than the site. Note this is the opposite of playwright.config.ts, which
32+
// runs fully parallel; correctness tests want the speed, timing measurements
33+
// want the quiet machine.
34+
fullyParallel: false,
35+
workers: 1,
36+
retries: 0,
37+
// A cold Lighthouse run is far slower than an ordinary assertion.
38+
timeout: 180_000,
39+
reporter: [['list']],
40+
// No `use` block: every test launches its own browser (Lighthouse needs a
41+
// debugging port the `page` fixture does not expose), so nothing here would
42+
// reach it. That also means the browser projects in playwright.config.ts have
43+
// no equivalent here — Lighthouse only runs under chromium.
44+
45+
webServer: {
46+
// `build:demo` is the wireit task, so it rebuilds the library first when
47+
// `packages/urlstate` has changed. Auditing the demo against a stale `dist`
48+
// would measure the previous release.
49+
command: 'pnpm run build:demo && pnpm run lighthouse:serve',
50+
url: PREVIEW_URL,
51+
reuseExistingServer: !process.env.CI,
52+
timeout: 300_000,
53+
},
54+
});

0 commit comments

Comments
 (0)