styleproof 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/README.md +84 -22
- package/bin/styleproof-init.mjs +132 -45
- package/dist/capture.d.ts +37 -5
- package/dist/capture.js +75 -11
- package/dist/coverage.d.ts +35 -0
- package/dist/coverage.js +35 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/routes.d.ts +29 -0
- package/dist/routes.js +84 -0
- package/dist/runner.d.ts +41 -6
- package/dist/runner.js +57 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,61 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.1.0] - 2026-06-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Coverage guard (`expected` / `exclude`).** `defineStyleMapCapture` now accepts
|
|
15
|
+
`expected` — the app's route/view universe — and emits a guard test that fails
|
|
16
|
+
when a route has no captured surface and isn't in `exclude`. It runs in the
|
|
17
|
+
normal test suite (no `STYLEMAP_DIR`, no browser — a static check), closing the
|
|
18
|
+
one hole captures can't catch on their own: a new page nobody added to
|
|
19
|
+
`surfaces` is invisible to the diff (no base capture, no head capture), so the
|
|
20
|
+
gate goes green having never looked at it. `exclude` is a `key → reason` ledger
|
|
21
|
+
of deliberate opt-outs; a key absent from `expected` (a renamed/removed route)
|
|
22
|
+
fails the guard too, so the ledger can't rot. Omit `expected` and behaviour is
|
|
23
|
+
unchanged. The pure `coverageGaps(captured, expected, exclude)` helper is also
|
|
24
|
+
exported. Closes the class of regression where a brand-new view's styles ship
|
|
25
|
+
uncaptured because the surface list silently drifted from the app's routes.
|
|
26
|
+
- **Coverage guard works out of the box for Next.js.** New `discoverNextRoutes()`
|
|
27
|
+
reads the App Router (`app/`) and Pages Router (`pages/`) at run time and returns
|
|
28
|
+
`{ key, path, dynamic }[]` (route groups/slots stripped, `[param]` flagged). `styleproof-init`
|
|
29
|
+
now detects a Next.js app and scaffolds a spec that wires both the surfaces and
|
|
30
|
+
`expected` to it — so a fresh install is protected without hand-wiring, and a page
|
|
31
|
+
added later is covered automatically. Non-Next projects get the previous starter
|
|
32
|
+
surface plus a commented guard block to point at their own route registry.
|
|
33
|
+
- **Network-aware settle (default on).** The settle now holds while the page's own
|
|
34
|
+
data requests are in flight — excluding long-lived `EventSource`/WebSocket streams
|
|
35
|
+
(handled by the live-region pass) — instead of only waiting for the computed-style
|
|
36
|
+
map to go quiet. So late-fetched content is captured **loaded, not mid-load**, and
|
|
37
|
+
the settle can't false-settle on a loading state before a slow backend responds —
|
|
38
|
+
the phantom-diff / self-check flake that a fixed wait produces under CI load. New
|
|
39
|
+
exported `trackInflightRequests(page)` arms the tracker; `defineStyleMapCapture`
|
|
40
|
+
arms it before each `go()` automatically.
|
|
41
|
+
Opt out with `stabilize.waitForRequests: false`.
|
|
42
|
+
- **`styleproof-init` scaffolds a production-build web server.** The generated
|
|
43
|
+
`playwright.config.ts` now includes a `webServer` that runs
|
|
44
|
+
`npm run build && npm run start` (reusing a server already up), so a fresh project
|
|
45
|
+
captures against a production build by default instead of a `next dev`-style server
|
|
46
|
+
whose JIT timing variance is the top source of capture flakes.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- **`selfCheck` defaults on while recording, off on replay.** The determinism guard
|
|
51
|
+
(capture twice, fail on drift) was opt-in (`STYLEPROOF_SELFCHECK=1`), so by default
|
|
52
|
+
nondeterminism shipped silently as a phantom diff. It now defaults **on when
|
|
53
|
+
recording** (no `replayFrom`), where live nondeterminism surfaces, and **off on
|
|
54
|
+
replay**, which is deterministic by construction — so a fresh
|
|
55
|
+
`defineStyleMapCapture({ surfaces })` auto-detects and names nondeterminism with no
|
|
56
|
+
2× cost on the replay run. `STYLEPROOF_SELFCHECK=1` still forces it on for both;
|
|
57
|
+
`selfCheck: false` opts out.
|
|
58
|
+
- **`styleproof-init` scaffolds a minimal `settle()`.** Now that the engine's
|
|
59
|
+
network-aware settle waits out in-flight data and fonts, freezes animations, and
|
|
60
|
+
blurs focus, the generated helper drops the hand-rolled `document.fonts.ready`,
|
|
61
|
+
animation-freeze, fixed `waitForTimeout`, and `networkidle` wait (the last an
|
|
62
|
+
active trap — it never fires against an SSE stream). It keeps only what the engine
|
|
63
|
+
can't know about — scroll-reveal — and `go()` is a plain `page.goto(...)`.
|
|
64
|
+
|
|
10
65
|
## [2.0.0] - 2026-06-22
|
|
11
66
|
|
|
12
67
|
### Changed
|
package/README.md
CHANGED
|
@@ -19,13 +19,58 @@ On every PR, StyleProof captures a `StyleMap` from the HEAD and from the base br
|
|
|
19
19
|
- **New surfaces don't block.** A surface that exists only on the PR head (no baseline to diff — e.g. the bootstrap PR that first adds the capture spec, or a brand-new page) is shown in the report under a `🆕 new surface` heading but never holds the status red and needs no sign-off. It becomes part of the baseline once merged.
|
|
20
20
|
- No committed baseline to maintain — the diff is HEAD-vs-base, so the report is _exactly what this PR changes_.
|
|
21
21
|
|
|
22
|
+
## Don't let a new page ship uncaptured
|
|
23
|
+
|
|
24
|
+
StyleProof diffs the surfaces your spec lists — so a page nobody added to the list is invisible to the gate. Its change has no base capture _and_ no head capture, so it never appears in any diff, and the status goes green having never looked at it. This is the one thing the captures can't catch on their own: a capture that was never taken.
|
|
25
|
+
|
|
26
|
+
Declare your app's route/view universe in `expected` and StyleProof emits a coverage-guard test in your **normal** suite (it runs even without `STYLEMAP_DIR` — it's a static check, no browser). It fails the moment a route exists with no surface, so a new page can't ship uncaptured:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { defineStyleMapCapture } from 'styleproof';
|
|
30
|
+
import { ROUTES } from '../app/routes'; // your registry — wherever routes live
|
|
31
|
+
|
|
32
|
+
defineStyleMapCapture({
|
|
33
|
+
dir: process.env.STYLEMAP_DIR,
|
|
34
|
+
surfaces: SURFACES,
|
|
35
|
+
expected: ROUTES.map((r) => r.id), // every route StyleProof should cover
|
|
36
|
+
exclude: { checkout: 'auth-gated — capture fixture pending' }, // visible, reviewed opt-outs (key → reason)
|
|
37
|
+
});
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
A route that's neither a captured surface nor an `exclude` entry fails the guard; an `exclude` key that isn't in `expected` (a renamed/removed route) fails too, so the opt-out ledger can't quietly rot. Captured surfaces beyond `expected` are fine — one route can have several states (`landing`, `landing-nav-open`). Omit `expected` and behaviour is unchanged.
|
|
41
|
+
|
|
42
|
+
**Next.js: wired for you.** Run `styleproof-init` in a Next.js project and the generated spec discovers your routes (App Router `app/` + Pages Router `pages/`) at run time and wires both the surfaces and `expected` to them — so it's protected out of the box, and a page you add later is covered automatically with nothing to keep in sync:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
import { defineStyleMapCapture, discoverNextRoutes } from 'styleproof';
|
|
46
|
+
|
|
47
|
+
const ROUTES = discoverNextRoutes(); // [{ key, path, dynamic }, …] from app/ + pages/
|
|
48
|
+
defineStyleMapCapture({
|
|
49
|
+
surfaces: ROUTES.filter((r) => !r.dynamic).map((r) => ({
|
|
50
|
+
key: r.key,
|
|
51
|
+
go: (p) => p.goto(r.path),
|
|
52
|
+
widths: [1280, 768, 390],
|
|
53
|
+
})),
|
|
54
|
+
expected: ROUTES.map((r) => r.key),
|
|
55
|
+
exclude: Object.fromEntries(
|
|
56
|
+
ROUTES.filter((r) => r.dynamic).map((r) => [
|
|
57
|
+
r.key,
|
|
58
|
+
`dynamic route ${r.path} — add a surface with a concrete param`,
|
|
59
|
+
]),
|
|
60
|
+
),
|
|
61
|
+
dir: process.env.STYLEMAP_DIR,
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`discoverNextRoutes(cwd?)` reads the filesystem only (route groups `(group)` and `@slots` stripped, `[param]`/`[...catchall]` flagged `dynamic`) — a heuristic, not a router; edit the generated spec for exotic routing. For any other framework, point `expected` at your own route registry as above.
|
|
66
|
+
|
|
22
67
|
## What a report looks like
|
|
23
68
|
|
|
24
|
-
One change — the hero CTA recoloured cyan → amber —
|
|
69
|
+
One change — the hero CTA recoloured cyan → amber — appears as a single section in the report: a side-by-side before/after cropped screenshot, a one-line summary, then the exact property change folded under a toggle.
|
|
25
70
|
|
|
26
71
|

|
|
27
72
|
|
|
28
|
-
As it renders in the
|
|
73
|
+
As it renders in the committed report (a plain-English bullet first — naming the theme token and showing the hex with a live colour swatch — then the exact table inside the toggle). The PR comment itself stays lean — a summary plus the approval box — and links here:
|
|
29
74
|
|
|
30
75
|
```text
|
|
31
76
|
### `a.btn-solid` · 1 element restyled
|
|
@@ -67,11 +112,7 @@ defineStyleMapCapture({
|
|
|
67
112
|
surfaces: [
|
|
68
113
|
{
|
|
69
114
|
key: 'landing',
|
|
70
|
-
go:
|
|
71
|
-
await page.goto('/');
|
|
72
|
-
await page.waitForLoadState('networkidle');
|
|
73
|
-
await page.evaluate(() => document.fonts.ready);
|
|
74
|
-
},
|
|
115
|
+
go: (page) => page.goto('/'), // that's it — StyleProof settles the page (in-flight data, fonts, animations) before it reads
|
|
75
116
|
widths: [1280, 768, 390], // one viewport per @media band
|
|
76
117
|
},
|
|
77
118
|
],
|
|
@@ -141,12 +182,31 @@ Copy both `capture` and `report` files to `.github/workflows/` (the `report` one
|
|
|
141
182
|
|
|
142
183
|
- **Record / replay.** The base capture records each surface's data responses (anything matching `**/api/**`) to a HAR; the head capture replays them, so the head renders _its_ code against the _base's_ data — the app's own JS/CSS still load live. Backend down during a run? Both sides replay the same recording, so there's no phantom diff. Point the head capture at the base's recording with `STYLEPROOF_REPLAY_FROM=<base dir>` (see the CI step above); tune the data boundary with `STYLEPROOF_REPLAY_URL` / `replayUrl` if your API isn't under `/api`.
|
|
143
184
|
- **Frozen clock.** `Date.now()` / `new Date()` are pinned to a fixed instant, so time-derived styling (`stale > 1h → red`) can't drift. Timers keep running, so settling still works.
|
|
144
|
-
- **Self-check**
|
|
185
|
+
- **Self-check** — captures each surface twice and fails if they differ, so a replay gap or unseeded randomness surfaces as a clear _"non-deterministic capture"_ error, never as a phantom change on an unrelated PR. **On by default while recording** (where live nondeterminism shows up); off on the replay run, which renders against the recorded HAR and is deterministic by construction. `STYLEPROOF_SELFCHECK=1` forces it on for both; `selfCheck: false` opts out.
|
|
145
186
|
- **Framework noise is skipped by default.** Non-visual and framework-injected elements never count as a change — `<meta>`/`<title>`/`<script>`/`<style>`/… (which Next.js streams into the body then hoists) and live regions like Next's `next-route-announcer`. A real stylesheet change still shows up in the affected elements' computed styles, not in the `<style>` tag. Add your own selectors with `ignore` — they extend this default, they don't replace it.
|
|
146
187
|
|
|
147
188
|
> Replay covers data the page _fetches_. If your app **server-renders** differently per environment (SSR feature flags, locale), still capture both sides with the same server env so the rendered HTML matches.
|
|
148
189
|
|
|
149
|
-
**Live pages just work.** Before each capture, StyleProof settles the page
|
|
190
|
+
**Live pages just work.** Before each capture, StyleProof settles the page, and the settle is **network-aware**: it holds while the page's data requests are in flight (excluding long-lived `EventSource`/WebSocket streams, which never finish) _and_ until the computed-style map stops changing. So async content (a fetch backfilling a grid, an SSE stream) is captured **loaded, not mid-load** — and, crucially, it **can't false-settle on the loading state before a slow backend's response arrives**. That's the failure mode of a fixed wait: against a slow server (e.g. a dev server under CI load) a timer settles on the loading skeleton one run and the loaded deck the next — a phantom diff / self-check flake. Waiting on the actual request removes it. Anything still moving on its own after that is detected as a live region and excluded from the diff, so a stream or ticker never reads as a change — no manual `ignore` needed. `defineStyleMapCapture` arms the request tracker before each `go()` automatically; for a direct `captureStyleMap` call, arm one before you navigate with `trackInflightRequests(page)` and pass `{ pendingRequests }`. Disable or tune with `{ stabilize: false }` / `{ stabilize: { quietFor, timeout, waitForRequests } }`.
|
|
191
|
+
|
|
192
|
+
**At a glance — almost everything is automatic.** The few knobs exist only for what StyleProof can't know about your app, and each says why:
|
|
193
|
+
|
|
194
|
+
| Handled for you — zero config | How |
|
|
195
|
+
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
196
|
+
| In-flight data, fonts, late layout | network-aware settle holds until requests finish _and_ the computed styles stop changing |
|
|
197
|
+
| Animations, transitions, focus ring, caret | frozen / blurred before the map is read |
|
|
198
|
+
| Clock-derived styling (`stale > 1h → red`) | `Date.now()` / `new Date()` frozen to a fixed instant |
|
|
199
|
+
| Framework & non-visual noise (`<script>`, route announcers) | skipped by default |
|
|
200
|
+
| Live / volatile regions (tickers, third-party embeds) | auto-detected as still-moving and excluded from the diff |
|
|
201
|
+
| Non-deterministic capture (replay gap, unseeded randomness) | self-check flags it _while recording_, with a named error |
|
|
202
|
+
|
|
203
|
+
| You set this — only because it's app-specific | Why it exists |
|
|
204
|
+
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
205
|
+
| `STYLEPROOF_REPLAY_FROM` (record / replay) | Base and head capture at different times against a live backend; replaying the base's recorded data pins the head to the same inputs, so the diff is **your code, not data drift**. The one piece of real setup. |
|
|
206
|
+
| `replayUrl` / `STYLEPROOF_REPLAY_URL` | Your data endpoints aren't under `**/api/**`. |
|
|
207
|
+
| `ignore: ['.selector']` | You want a region gone **explicitly** — auto-exclude already handles most live regions, but a known-noisy element reads clearer named. |
|
|
208
|
+
| `clockTime` | Your styling keys off a **specific** date, not just "now". |
|
|
209
|
+
| `stabilize: { quietFor, timeout }` | An unusually slow surface needs a longer quiet window before the map is read. |
|
|
150
210
|
|
|
151
211
|
## Optional: content layer (advisory)
|
|
152
212
|
|
|
@@ -200,17 +260,19 @@ It's **asynchronous by design**: approval is a checkbox tick handled by a separa
|
|
|
200
260
|
|
|
201
261
|
**Capture spec `defineStyleMapCapture({ surfaces, … })`** — determinism is on by default; you rarely set more than `surfaces` and `dir`:
|
|
202
262
|
|
|
203
|
-
| Option | Default | Purpose
|
|
204
|
-
| ------------- | --------------------------- |
|
|
205
|
-
| `surfaces` | _required_ | Page states to certify — each `{ key, go, widths, ignore?, height? }`. `go(page)` drives to a settled state.
|
|
206
|
-
| `
|
|
207
|
-
| `
|
|
208
|
-
| `
|
|
209
|
-
| `
|
|
210
|
-
| `
|
|
211
|
-
| `
|
|
212
|
-
| `
|
|
213
|
-
| `
|
|
263
|
+
| Option | Default | Purpose |
|
|
264
|
+
| ------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
265
|
+
| `surfaces` | _required_ | Page states to certify — each `{ key, go, widths, ignore?, height? }`. `go(page)` drives to a settled state. |
|
|
266
|
+
| `expected` | _none_ | Your route/view universe. Emits a coverage-guard test (runs without a capture dir) that fails when a route has no surface and isn't excluded — so a new page can't ship uncaptured. |
|
|
267
|
+
| `exclude` | `{}` | `key → reason` for routes deliberately not captured. Keeps the guard green for known gaps; a key absent from `expected` fails the guard, so the ledger can't go stale. |
|
|
268
|
+
| `dir` | `STYLEMAP_DIR` | Output label (`base`/`head`); the spec is **inert until set**, so it sits safely beside your other specs. |
|
|
269
|
+
| `replayFrom` | `STYLEPROOF_REPLAY_FROM` | Baseline dir whose recorded responses to replay. Unset → this run **records** its HAR for the comparison to use. |
|
|
270
|
+
| `replayUrl` | `**/api/**` (`…REPLAY_URL`) | URL glob for the data boundary to record/replay; everything else (JS/CSS/fonts) loads live so the code runs. |
|
|
271
|
+
| `freezeClock` | `true` | Pin `Date.now()`/`new Date()` so time-derived styling can't drift; timers keep running so settling still works. |
|
|
272
|
+
| `clockTime` | `2025-01-01T00:00:00Z` | The frozen instant. |
|
|
273
|
+
| `selfCheck` | on while recording | Capture each surface twice and fail on any difference — proves the capture is deterministic. Off on the replay run; `STYLEPROOF_SELFCHECK=1` forces both. |
|
|
274
|
+
| `screenshots` | `true` | Save full-page screenshots for the report's before/after crops. |
|
|
275
|
+
| `baseDir` | `__stylemaps__` | Output root directory. |
|
|
214
276
|
|
|
215
277
|
Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<style>`/… and `next-route-announcer`) are skipped automatically; a surface's `ignore` adds to that default, it doesn't replace it.
|
|
216
278
|
|
|
@@ -225,8 +287,8 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
|
|
|
225
287
|
|
|
226
288
|
**CLIs** (every flag accepts `--flag value` and `--flag=value`; `--help` lists all):
|
|
227
289
|
|
|
228
|
-
- `styleproof-init` — scaffold the capture spec (and a starter `playwright.config.ts`
|
|
229
|
-
- `styleproof-diff <beforeDir> <afterDir>` — the certify gate; exits `1` on
|
|
290
|
+
- `styleproof-init` — scaffold the capture spec (and, if none exists, a starter `playwright.config.ts` whose `webServer` **builds and serves a production build**, so captures never run against a flaky dev server).
|
|
291
|
+
- `styleproof-diff <beforeDir> <afterDir>` — the certify gate; exits `0` certified (identical), `1` on a diff, `2` on a usage/capture error, `3` when only new surfaces are present (no baseline to diff against).
|
|
230
292
|
- `styleproof-report <beforeDir> <afterDir> --out <dir>` — render the diff to a Markdown report with before/after crops. Add `--include-content` for the opt-in, advisory content section (see above).
|
|
231
293
|
|
|
232
294
|
A programmatic API (`captureStyleMap`, `diffStyleMaps`, `generateStyleMapReport`, …) is also exported. For the capture internals, the approve-workflow trust model, and how to contribute, see [CONTRIBUTING](https://github.com/BenSheridanEdwards/StyleProof/blob/main/CONTRIBUTING.md) and the [`example/`](https://github.com/BenSheridanEdwards/StyleProof/tree/main/example) workflows.
|
package/bin/styleproof-init.mjs
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Writes:
|
|
8
8
|
* - <dir> (default e2e/styleproof.spec.ts): a starter capture spec with a
|
|
9
|
-
*
|
|
10
|
-
* animation
|
|
9
|
+
* minimal settle() helper (triggers scroll-reveal content; StyleProof itself
|
|
10
|
+
* handles fonts, animation freeze, and the settle). For a detected Next.js app it derives surfaces AND
|
|
11
|
+
* the `expected` coverage guard from the app's routes at run time, so a page
|
|
12
|
+
* added later can't ship without a surface; otherwise it writes one sample
|
|
13
|
+
* surface plus a commented guard block to wire to your own route registry.
|
|
11
14
|
* - playwright.config.ts: only if one does not already exist, so an existing
|
|
12
15
|
* Playwright project is never disturbed.
|
|
13
16
|
*
|
|
@@ -17,6 +20,7 @@
|
|
|
17
20
|
*/
|
|
18
21
|
import fs from 'node:fs';
|
|
19
22
|
import path from 'node:path';
|
|
23
|
+
import { discoverNextRoutes } from '../dist/index.js';
|
|
20
24
|
|
|
21
25
|
const HELP = `styleproof-init — scaffold a styleproof capture spec
|
|
22
26
|
|
|
@@ -30,13 +34,14 @@ options:
|
|
|
30
34
|
-h, --help show this help
|
|
31
35
|
|
|
32
36
|
What it writes:
|
|
33
|
-
- the spec at --dir, with a settle() helper (
|
|
34
|
-
|
|
37
|
+
- the spec at --dir, with a minimal settle() helper (scroll-reveal only).
|
|
38
|
+
In a Next.js app it discovers your routes at run time and wires both the
|
|
39
|
+
surfaces and the \`expected\` coverage guard to them, so a new page can't ship
|
|
40
|
+
uncaptured. Otherwise it writes one sample surface + a commented guard block.
|
|
35
41
|
- playwright.config.ts, only if absent (an existing one is left untouched)
|
|
36
42
|
|
|
37
43
|
After running, capture a baseline against a PRODUCTION build:
|
|
38
44
|
STYLEMAP_DIR=baseline npx playwright test styleproof
|
|
39
|
-
git add e2e/__stylemaps__/baseline && git commit -m "chore: stylemap baseline"
|
|
40
45
|
|
|
41
46
|
To certify a refactor:
|
|
42
47
|
STYLEMAP_DIR=before npx playwright test styleproof
|
|
@@ -70,10 +75,34 @@ if (!specPath) {
|
|
|
70
75
|
process.exit(2);
|
|
71
76
|
}
|
|
72
77
|
|
|
73
|
-
|
|
74
|
-
|
|
78
|
+
// Captures read whatever is in front of them, so the page must be settled and
|
|
79
|
+
// deterministic first — this helper is shared by both spec variants below.
|
|
80
|
+
const SETTLE = `// StyleProof settles the page for you before it reads — it waits out in-flight data
|
|
81
|
+
// and fonts, freezes animations/transitions, and blurs focus. The one thing it can't
|
|
82
|
+
// know about is *scroll-reveal* content: elements an IntersectionObserver mounts (or
|
|
83
|
+
// fades in) only once they're scrolled into view. settle() triggers that — it scrolls
|
|
84
|
+
// the page so those reveals fire, forces common reveal markers to their final state so
|
|
85
|
+
// nothing is caught mid-fade, then returns to the top. Tune the selectors to match
|
|
86
|
+
// your project. No reveal-on-scroll content? Delete settle() and use the one-liner
|
|
87
|
+
// \`go: (page) => page.goto('/')\`.
|
|
88
|
+
async function settle(page: Page) {
|
|
89
|
+
await page.addStyleTag({
|
|
90
|
+
content: \`.reveal, [data-reveal], .fade-in, .animate-in {
|
|
91
|
+
opacity: 1 !important;
|
|
92
|
+
transform: none !important;
|
|
93
|
+
visibility: visible !important;
|
|
94
|
+
}\`,
|
|
95
|
+
});
|
|
96
|
+
await page.evaluate(async () => {
|
|
97
|
+
for (let y = 0; y < document.body.scrollHeight; y += window.innerHeight) {
|
|
98
|
+
window.scrollTo(0, y);
|
|
99
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
100
|
+
}
|
|
101
|
+
window.scrollTo(0, 0);
|
|
102
|
+
});
|
|
103
|
+
}`;
|
|
75
104
|
|
|
76
|
-
|
|
105
|
+
const HEADER = `/**
|
|
77
106
|
* styleproof capture spec (generated by \`styleproof-init\`).
|
|
78
107
|
*
|
|
79
108
|
* Each surface is one deterministic page state; widths sweep one viewport per
|
|
@@ -84,46 +113,63 @@ import { defineStyleMapCapture, type Surface } from 'styleproof';
|
|
|
84
113
|
* ...refactor your CSS...
|
|
85
114
|
* STYLEMAP_DIR=after npx playwright test styleproof # capture again
|
|
86
115
|
* npx styleproof-diff __stylemaps__/before __stylemaps__/after
|
|
87
|
-
|
|
116
|
+
*/`;
|
|
88
117
|
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
118
|
+
// Next.js detected: derive both surfaces and the coverage guard from the app's
|
|
119
|
+
// routes AT RUN TIME, so a page added later is in `expected` automatically and
|
|
120
|
+
// fails the guard until it has a surface — no static list to drift.
|
|
121
|
+
const NEXT_SPEC = `import type { Page } from '@playwright/test';
|
|
122
|
+
import { defineStyleMapCapture, discoverNextRoutes, type Surface } from 'styleproof';
|
|
123
|
+
|
|
124
|
+
${HEADER}
|
|
125
|
+
|
|
126
|
+
${SETTLE}
|
|
127
|
+
|
|
128
|
+
// Routes discovered from your Next.js app (app/ + pages/) at RUN TIME — so a page
|
|
129
|
+
// you add later is covered automatically, with no surface list to keep in sync
|
|
130
|
+
// (that drift is exactly what lets a new page ship unverified). Edit freely; this
|
|
131
|
+
// is your spec. Static routes each get a capture; dynamic [param] routes can't be
|
|
132
|
+
// navigated without a value, so they're listed in \`exclude\` until you add a
|
|
133
|
+
// surface with a concrete param.
|
|
134
|
+
const ROUTES = discoverNextRoutes();
|
|
135
|
+
|
|
136
|
+
const SURFACES: Surface[] = ROUTES.filter((r) => !r.dynamic).map((r) => ({
|
|
137
|
+
key: r.key,
|
|
138
|
+
go: async (page) => {
|
|
139
|
+
await page.goto(r.path);
|
|
140
|
+
await settle(page);
|
|
141
|
+
},
|
|
142
|
+
ignore: [], // e.g. ['.live-feed', '.ad-slot'] for nondeterministic regions
|
|
143
|
+
widths: [1280, 768, 390],
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
defineStyleMapCapture({
|
|
147
|
+
surfaces: SURFACES,
|
|
148
|
+
// Coverage guard: every known route must be a captured surface or excluded, or
|
|
149
|
+
// the suite fails (it runs without STYLEMAP_DIR — a static check, no browser).
|
|
150
|
+
// A new page with no surface can't slip through the gate unseen.
|
|
151
|
+
expected: ROUTES.map((r) => r.key),
|
|
152
|
+
exclude: Object.fromEntries(
|
|
153
|
+
ROUTES.filter((r) => r.dynamic).map((r) => [r.key, \`dynamic route (\${r.path}) — add a surface with a concrete param\`]),
|
|
154
|
+
),
|
|
155
|
+
dir: process.env.STYLEMAP_DIR,
|
|
156
|
+
});
|
|
157
|
+
`;
|
|
158
|
+
|
|
159
|
+
// Non-Next project: one sample surface + a commented guard block to wire to
|
|
160
|
+
// whatever the project uses as a route/view registry.
|
|
161
|
+
const GENERIC_SPEC = `import type { Page } from '@playwright/test';
|
|
162
|
+
import { defineStyleMapCapture, type Surface } from 'styleproof';
|
|
163
|
+
|
|
164
|
+
${HEADER}
|
|
165
|
+
|
|
166
|
+
${SETTLE}
|
|
121
167
|
|
|
122
168
|
const SURFACES: Surface[] = [
|
|
123
169
|
{
|
|
124
170
|
key: 'home',
|
|
125
171
|
go: async (page) => {
|
|
126
|
-
await page.goto('/'
|
|
172
|
+
await page.goto('/');
|
|
127
173
|
await settle(page);
|
|
128
174
|
},
|
|
129
175
|
ignore: [], // e.g. ['.live-feed', '.ad-slot'] for nondeterministic regions
|
|
@@ -133,18 +179,44 @@ const SURFACES: Surface[] = [
|
|
|
133
179
|
// tabs, form errors — anything whose styling you want certified.
|
|
134
180
|
];
|
|
135
181
|
|
|
136
|
-
defineStyleMapCapture({
|
|
182
|
+
defineStyleMapCapture({
|
|
183
|
+
surfaces: SURFACES,
|
|
184
|
+
// Coverage guard (recommended): declare every route your app knows about so a
|
|
185
|
+
// newly added page can't ship without a surface. Wire \`expected\` to your route
|
|
186
|
+
// registry — a routes/views list, your router config, or a glob of your pages —
|
|
187
|
+
// and StyleProof fails the suite (no STYLEMAP_DIR needed) when a route has no
|
|
188
|
+
// surface and isn't excluded. (A Next.js app gets this auto-wired; see
|
|
189
|
+
// \`discoverNextRoutes\` in the README.)
|
|
190
|
+
// expected: ROUTES.map((r) => r.id),
|
|
191
|
+
// exclude: { checkout: 'auth-gated — fixture pending' },
|
|
192
|
+
dir: process.env.STYLEMAP_DIR,
|
|
193
|
+
});
|
|
137
194
|
`;
|
|
138
195
|
|
|
139
196
|
const CONFIG = `import { defineConfig, devices } from '@playwright/test';
|
|
140
197
|
|
|
141
|
-
// Generated by styleproof-init.
|
|
142
|
-
//
|
|
198
|
+
// Generated by styleproof-init.
|
|
199
|
+
//
|
|
200
|
+
// Capture against a PRODUCTION build, never a dev server. Dev servers (\`next dev\`,
|
|
201
|
+
// \`vite\`, …) JIT-compile each route on first request — slow and TIMING-VARIABLE
|
|
202
|
+
// under parallel CI load, so a capture can settle on the loading state on one run and
|
|
203
|
+
// the loaded state on the next: phantom diffs and self-check flakes. A built-and-served
|
|
204
|
+
// app serves precompiled routes at consistent timing. (StyleProof's settle waits for
|
|
205
|
+
// in-flight data either way, but a production build removes the variance at the source.)
|
|
143
206
|
export default defineConfig({
|
|
144
207
|
timeout: 120_000,
|
|
145
208
|
use: {
|
|
146
209
|
baseURL: process.env.BASE_URL || '${baseUrl}',
|
|
147
210
|
},
|
|
211
|
+
// Build once, then serve THAT production build for the captures — so you can't
|
|
212
|
+
// accidentally capture a dev server. Adjust the command to your framework (e.g.
|
|
213
|
+
// \`vite build && vite preview\`); a server already running locally is reused.
|
|
214
|
+
webServer: {
|
|
215
|
+
command: 'npm run build && npm run start',
|
|
216
|
+
url: process.env.BASE_URL || '${baseUrl}',
|
|
217
|
+
reuseExistingServer: !process.env.CI,
|
|
218
|
+
timeout: 600_000, // a cold production build can take a few minutes
|
|
219
|
+
},
|
|
148
220
|
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
|
149
221
|
});
|
|
150
222
|
`;
|
|
@@ -157,11 +229,26 @@ function writeFileSafe(file, contents, { force: f } = {}) {
|
|
|
157
229
|
return { wrote: true, exists };
|
|
158
230
|
}
|
|
159
231
|
|
|
232
|
+
// Choose the scaffold: routes-aware when this is a Next.js app with discoverable
|
|
233
|
+
// routes, else the generic one-surface starter.
|
|
234
|
+
const routes = discoverNextRoutes(process.cwd());
|
|
235
|
+
const isNext = routes.length > 0;
|
|
236
|
+
const SPEC = isNext ? NEXT_SPEC : GENERIC_SPEC;
|
|
237
|
+
|
|
160
238
|
let wroteSomething = false;
|
|
161
239
|
|
|
162
240
|
const spec = writeFileSafe(specPath, SPEC, { force });
|
|
163
241
|
if (spec.wrote) {
|
|
164
242
|
console.log(`${spec.exists ? 'overwrote' : 'created'} ${specPath}`);
|
|
243
|
+
if (isNext) {
|
|
244
|
+
const dynamic = routes.filter((r) => r.dynamic).length;
|
|
245
|
+
console.log(
|
|
246
|
+
` detected ${routes.length} Next.js route(s) — wired surfaces + the \`expected\` coverage guard to them` +
|
|
247
|
+
(dynamic ? ` (${dynamic} dynamic route(s) excluded pending a concrete param)` : ''),
|
|
248
|
+
);
|
|
249
|
+
} else {
|
|
250
|
+
console.log(' no Next.js routes detected — wrote a starter surface + a commented coverage-guard block');
|
|
251
|
+
}
|
|
165
252
|
wroteSomething = true;
|
|
166
253
|
} else {
|
|
167
254
|
console.log(`${specPath} already exists — left untouched (use --force to overwrite)`);
|
package/dist/capture.d.ts
CHANGED
|
@@ -92,16 +92,24 @@ export type CaptureOptions = {
|
|
|
92
92
|
* ticker never reads as a change — no manual `ignore` needed. Text-only churn
|
|
93
93
|
* (a clock, "2m ago") never matters: the diff compares computed style, not
|
|
94
94
|
* text. Pass `false` to capture the exact frame `go()` left, or `{ interval,
|
|
95
|
-
* quietFor, timeout }`
|
|
96
|
-
* that counts as settled,
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
95
|
+
* quietFor, timeout, waitForRequests }` to tune the poll cadence, the no-change
|
|
96
|
+
* window that counts as settled, the budget (ms), and the network signal.
|
|
97
|
+
*
|
|
98
|
+
* By default the settle is **network-aware** (`waitForRequests: true`): it won't
|
|
99
|
+
* settle on the brief DOM lull BEFORE a `fetch`/XHR response arrives — it holds
|
|
100
|
+
* while data requests are in flight (excluding long-lived `EventSource`/WebSocket
|
|
101
|
+
* streams, which never finish), so late-fetched content is captured loaded, not
|
|
102
|
+
* mid-load. This is the signal a DOM-quiet window alone lacks: without it, a slow
|
|
103
|
+
* backend (e.g. a dev server under CI load) settles on the loading state on one run
|
|
104
|
+
* and the loaded state on the next — a self-check flake. Anything still moving at
|
|
105
|
+
* `timeout` is treated as a live region. Set `waitForRequests: false` to settle on
|
|
106
|
+
* DOM quiet alone.
|
|
100
107
|
*/
|
|
101
108
|
stabilize?: boolean | {
|
|
102
109
|
interval?: number;
|
|
103
110
|
quietFor?: number;
|
|
104
111
|
timeout?: number;
|
|
112
|
+
waitForRequests?: boolean;
|
|
105
113
|
};
|
|
106
114
|
/**
|
|
107
115
|
* Capture forced :hover/:focus/:active state deltas (default true). This is
|
|
@@ -129,10 +137,34 @@ export type CaptureOptions = {
|
|
|
129
137
|
* guards styles, since text now participates in change detection.
|
|
130
138
|
*/
|
|
131
139
|
captureText?: boolean;
|
|
140
|
+
/**
|
|
141
|
+
* Advanced/internal: a getter for the count of in-flight data requests, supplied by
|
|
142
|
+
* a tracker ({@link trackInflightRequests}) armed BEFORE navigation so the page's own
|
|
143
|
+
* load fetches are counted by the network-aware settle. `defineStyleMapCapture`
|
|
144
|
+
* wires this automatically. Omit it for a direct `captureStyleMap` call and the
|
|
145
|
+
* settle arms its own tracker from capture time — which can't see a request already
|
|
146
|
+
* in flight when you called it (arm one yourself before `goto` if that matters).
|
|
147
|
+
*/
|
|
148
|
+
pendingRequests?: () => number;
|
|
132
149
|
};
|
|
133
150
|
/** True if `path` is one of `roots` or a structural descendant of one. Shared by
|
|
134
151
|
* the capture (excluding live regions) and the diff (skipping them). */
|
|
135
152
|
export declare function isUnder(path: string, roots: string[]): boolean;
|
|
153
|
+
/**
|
|
154
|
+
* Track in-flight DATA requests on `page` so the network-aware settle can wait for
|
|
155
|
+
* late-loading content to ARRIVE, not just for the DOM to go briefly quiet (the lull
|
|
156
|
+
* before a response paints). Long-lived streams (EventSource/WebSocket) never finish,
|
|
157
|
+
* so they're excluded — their painted state is handled by the live-region pass.
|
|
158
|
+
*
|
|
159
|
+
* Attach BEFORE navigation to count the page's OWN load fetches: a request already in
|
|
160
|
+
* flight when you call `captureStyleMap` fired its `request` event before any listener
|
|
161
|
+
* attached there, so only a tracker armed earlier (the runner does this before `go()`)
|
|
162
|
+
* can see it.
|
|
163
|
+
*/
|
|
164
|
+
export declare function trackInflightRequests(page: Page): {
|
|
165
|
+
pending: () => number;
|
|
166
|
+
dispose: () => void;
|
|
167
|
+
};
|
|
136
168
|
/**
|
|
137
169
|
* Capture the page's complete style map. Drive the page to the state you want
|
|
138
170
|
* first (navigate, open menus); by default the capture then auto-settles the
|
package/dist/capture.js
CHANGED
|
@@ -322,7 +322,7 @@ function changedElementPaths(a, b) {
|
|
|
322
322
|
* Reuses `capturePage` (motion already frozen by the caller), so only
|
|
323
323
|
* content/layout churn — not an animation frame — keeps it from settling.
|
|
324
324
|
*/
|
|
325
|
-
async function stabilizePage(page, ignore, interval, quietFor, timeout, captureText) {
|
|
325
|
+
async function stabilizePage(page, ignore, interval, quietFor, timeout, captureText, pending) {
|
|
326
326
|
// captureText is threaded in so text churn participates in settle detection:
|
|
327
327
|
// a clock/ticker whose text changes (with no style change) keeps the map from
|
|
328
328
|
// settling and is excluded as a live region, exactly as style churn already is.
|
|
@@ -340,8 +340,16 @@ async function stabilizePage(page, ignore, interval, quietFor, timeout, captureT
|
|
|
340
340
|
lastChangeAt = Date.now();
|
|
341
341
|
recent = changed;
|
|
342
342
|
}
|
|
343
|
+
else if (pending() > 0) {
|
|
344
|
+
// The DOM is momentarily quiet, but data requests are still in flight — this
|
|
345
|
+
// is the lull BEFORE the response paints, not a settled state. Hold the quiet
|
|
346
|
+
// window so we wait for the content to ARRIVE (no live-region path to record;
|
|
347
|
+
// network activity isn't a mutating element). Long-lived streams are excluded
|
|
348
|
+
// by the caller, so this can't hang on an SSE that never finishes.
|
|
349
|
+
lastChangeAt = Date.now();
|
|
350
|
+
}
|
|
343
351
|
else if (Date.now() - lastChangeAt >= quietFor) {
|
|
344
|
-
return []; // unchanged for the full quiet window → settled
|
|
352
|
+
return []; // DOM unchanged AND network idle for the full quiet window → settled
|
|
345
353
|
}
|
|
346
354
|
}
|
|
347
355
|
return recent; // never went quiet for quietFor within budget → still-moving paths are live
|
|
@@ -374,19 +382,75 @@ function capturePageTokens() {
|
|
|
374
382
|
probe.remove();
|
|
375
383
|
return tokens;
|
|
376
384
|
}
|
|
385
|
+
/**
|
|
386
|
+
* Track in-flight DATA requests on `page` so the network-aware settle can wait for
|
|
387
|
+
* late-loading content to ARRIVE, not just for the DOM to go briefly quiet (the lull
|
|
388
|
+
* before a response paints). Long-lived streams (EventSource/WebSocket) never finish,
|
|
389
|
+
* so they're excluded — their painted state is handled by the live-region pass.
|
|
390
|
+
*
|
|
391
|
+
* Attach BEFORE navigation to count the page's OWN load fetches: a request already in
|
|
392
|
+
* flight when you call `captureStyleMap` fired its `request` event before any listener
|
|
393
|
+
* attached there, so only a tracker armed earlier (the runner does this before `go()`)
|
|
394
|
+
* can see it.
|
|
395
|
+
*/
|
|
396
|
+
export function trackInflightRequests(page) {
|
|
397
|
+
const inflight = new Set();
|
|
398
|
+
const isStream = (r) => {
|
|
399
|
+
const t = r.resourceType();
|
|
400
|
+
return t === 'eventsource' || t === 'websocket';
|
|
401
|
+
};
|
|
402
|
+
const onStart = (r) => {
|
|
403
|
+
if (!isStream(r))
|
|
404
|
+
inflight.add(r);
|
|
405
|
+
};
|
|
406
|
+
const onEnd = (r) => {
|
|
407
|
+
inflight.delete(r);
|
|
408
|
+
};
|
|
409
|
+
page.on('request', onStart);
|
|
410
|
+
page.on('requestfinished', onEnd);
|
|
411
|
+
page.on('requestfailed', onEnd);
|
|
412
|
+
return {
|
|
413
|
+
pending: () => inflight.size,
|
|
414
|
+
dispose: () => {
|
|
415
|
+
page.off('request', onStart);
|
|
416
|
+
page.off('requestfinished', onEnd);
|
|
417
|
+
page.off('requestfailed', onEnd);
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
}
|
|
377
421
|
/** Settle the page and return the paths of live regions to exclude. */
|
|
378
|
-
async function detectVolatile(page, ignore, stabilize, captureText) {
|
|
422
|
+
async function detectVolatile(page, ignore, stabilize, captureText, externalPending) {
|
|
379
423
|
if (stabilize === false)
|
|
380
424
|
return [];
|
|
381
425
|
const opt = typeof stabilize === 'object' ? stabilize : {};
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
426
|
+
const waitForRequests = opt.waitForRequests ?? true;
|
|
427
|
+
// Prefer the runner's pre-navigation tracker (it counts the page's load fetches);
|
|
428
|
+
// otherwise arm one here as a fallback for requests that fire after this call.
|
|
429
|
+
let pending = () => 0;
|
|
430
|
+
let dispose = () => { };
|
|
431
|
+
if (waitForRequests) {
|
|
432
|
+
if (externalPending) {
|
|
433
|
+
pending = externalPending;
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
const t = trackInflightRequests(page);
|
|
437
|
+
pending = t.pending;
|
|
438
|
+
dispose = t.dispose;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
const volatile = await stabilizePage(page, ignore, opt.interval || 150, opt.quietFor || 600, opt.timeout || 5000, captureText, pending);
|
|
443
|
+
if (volatile.length) {
|
|
444
|
+
// eslint-disable-next-line no-console
|
|
445
|
+
console.warn(`styleproof: ${volatile.length} live region(s) kept changing on their own and were excluded from ` +
|
|
446
|
+
'this capture (nondeterministic — a stream, ticker, or late-loading content). The diff skips them so ' +
|
|
447
|
+
'they never read as a change. If a real change is being hidden, settle the page in go() or raise stabilize.timeout.');
|
|
448
|
+
}
|
|
449
|
+
return volatile;
|
|
450
|
+
}
|
|
451
|
+
finally {
|
|
452
|
+
dispose();
|
|
388
453
|
}
|
|
389
|
-
return volatile;
|
|
390
454
|
}
|
|
391
455
|
/** Drop live regions (and their subtrees) from the base capture, in Node so the
|
|
392
456
|
* serialized capturePage stays a pure snapshot. */
|
|
@@ -451,7 +515,7 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
451
515
|
// the same loaded state, and collect any region still changing on its own
|
|
452
516
|
// (a live stream/ticker) to exclude — animations are frozen above, so only
|
|
453
517
|
// real content/layout churn lands here.
|
|
454
|
-
const volatile = await detectVolatile(page, ignore, stabilize, captureText);
|
|
518
|
+
const volatile = await detectVolatile(page, ignore, stabilize, captureText, options.pendingRequests);
|
|
455
519
|
const base = await page.evaluate(capturePage, { ignore, motionOnly: false, captureText });
|
|
456
520
|
dropVolatile(base.elements, volatile);
|
|
457
521
|
warnUntraversed(base.shadowHosts, base.sameOriginFrames);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage guard for the surface list.
|
|
3
|
+
*
|
|
4
|
+
* StyleProof captures exactly the surfaces a spec declares, and the diff matches
|
|
5
|
+
* surfaces by key — so a route nobody added to `surfaces` is invisible to the
|
|
6
|
+
* gate: the change it introduces has no baseline capture AND no head capture, so
|
|
7
|
+
* it never appears in any diff. The gate goes green having never looked at it.
|
|
8
|
+
* This is the one failure StyleProof can't catch from the captures alone, because
|
|
9
|
+
* it's about a capture that was never taken.
|
|
10
|
+
*
|
|
11
|
+
* `expected` closes the hole: a spec declares its full route/surface universe
|
|
12
|
+
* (e.g. an app's view registry), and the guard fails when that universe drifts
|
|
13
|
+
* from what's actually captured — turning a silent coverage hole into a red test,
|
|
14
|
+
* in the app's own suite, the moment the route is added.
|
|
15
|
+
*/
|
|
16
|
+
export type CoverageGaps = {
|
|
17
|
+
/** Expected surfaces that are neither captured nor explicitly excluded. */
|
|
18
|
+
uncovered: string[];
|
|
19
|
+
/** `exclude` entries absent from `expected` — a renamed or removed route whose
|
|
20
|
+
* opt-out has gone stale (the same drift, in reverse). */
|
|
21
|
+
staleExclusions: string[];
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Compare the captured surface keys against a declared `expected` universe.
|
|
25
|
+
*
|
|
26
|
+
* A surface is covered if it's captured OR listed in `exclude` (a deliberate,
|
|
27
|
+
* documented opt-out — `key → reason`). Captured surfaces NOT in `expected` are
|
|
28
|
+
* allowed: one route legitimately has several captured states (`landing`,
|
|
29
|
+
* `landing-nav-open`), and only the routes themselves form the universe.
|
|
30
|
+
*
|
|
31
|
+
* Pure and side-effect-free so it's unit-testable; `defineStyleMapCapture` wraps
|
|
32
|
+
* it in a Playwright test that runs in the normal suite (not gated on a capture
|
|
33
|
+
* dir), and it's exported so a consumer can assert coverage however it likes.
|
|
34
|
+
*/
|
|
35
|
+
export declare function coverageGaps(capturedKeys: Iterable<string>, expected: Iterable<string>, exclude?: Record<string, string>): CoverageGaps;
|
package/dist/coverage.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coverage guard for the surface list.
|
|
3
|
+
*
|
|
4
|
+
* StyleProof captures exactly the surfaces a spec declares, and the diff matches
|
|
5
|
+
* surfaces by key — so a route nobody added to `surfaces` is invisible to the
|
|
6
|
+
* gate: the change it introduces has no baseline capture AND no head capture, so
|
|
7
|
+
* it never appears in any diff. The gate goes green having never looked at it.
|
|
8
|
+
* This is the one failure StyleProof can't catch from the captures alone, because
|
|
9
|
+
* it's about a capture that was never taken.
|
|
10
|
+
*
|
|
11
|
+
* `expected` closes the hole: a spec declares its full route/surface universe
|
|
12
|
+
* (e.g. an app's view registry), and the guard fails when that universe drifts
|
|
13
|
+
* from what's actually captured — turning a silent coverage hole into a red test,
|
|
14
|
+
* in the app's own suite, the moment the route is added.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Compare the captured surface keys against a declared `expected` universe.
|
|
18
|
+
*
|
|
19
|
+
* A surface is covered if it's captured OR listed in `exclude` (a deliberate,
|
|
20
|
+
* documented opt-out — `key → reason`). Captured surfaces NOT in `expected` are
|
|
21
|
+
* allowed: one route legitimately has several captured states (`landing`,
|
|
22
|
+
* `landing-nav-open`), and only the routes themselves form the universe.
|
|
23
|
+
*
|
|
24
|
+
* Pure and side-effect-free so it's unit-testable; `defineStyleMapCapture` wraps
|
|
25
|
+
* it in a Playwright test that runs in the normal suite (not gated on a capture
|
|
26
|
+
* dir), and it's exported so a consumer can assert coverage however it likes.
|
|
27
|
+
*/
|
|
28
|
+
export function coverageGaps(capturedKeys, expected, exclude = {}) {
|
|
29
|
+
const captured = new Set(capturedKeys);
|
|
30
|
+
const expectedList = [...expected];
|
|
31
|
+
const expectedSet = new Set(expectedList);
|
|
32
|
+
const uncovered = expectedList.filter((k) => !captured.has(k) && !(k in exclude));
|
|
33
|
+
const staleExclusions = Object.keys(exclude).filter((k) => !expectedSet.has(k));
|
|
34
|
+
return { uncovered, staleExclusions };
|
|
35
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
export { captureStyleMap, saveStyleMap, loadStyleMap } from './capture.js';
|
|
1
|
+
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
|
|
2
2
|
export type { StyleMap, CaptureOptions, ElementEntry, Rect } from './capture.js';
|
|
3
3
|
export { defineStyleMapCapture } from './runner.js';
|
|
4
4
|
export type { Surface, DefineOptions } from './runner.js';
|
|
5
|
+
export { coverageGaps } from './coverage.js';
|
|
6
|
+
export type { CoverageGaps } from './coverage.js';
|
|
7
|
+
export { discoverNextRoutes } from './routes.js';
|
|
8
|
+
export type { DiscoveredRoute } from './routes.js';
|
|
5
9
|
export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
|
|
6
10
|
export type { Finding, PropChange, SurfaceDiff, DiffCounts, ContentChange } from './diff.js';
|
|
7
11
|
export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
export { captureStyleMap, saveStyleMap, loadStyleMap } from './capture.js';
|
|
1
|
+
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
|
|
2
2
|
export { defineStyleMapCapture } from './runner.js';
|
|
3
|
+
export { coverageGaps } from './coverage.js';
|
|
4
|
+
export { discoverNextRoutes } from './routes.js';
|
|
3
5
|
export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
|
|
4
6
|
export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
|
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort route discovery for Next.js projects, so the coverage guard works
|
|
3
|
+
* out of the box: a generated spec calls this at run time, so `expected` reflects
|
|
4
|
+
* the app's *current* routes — a newly added page appears automatically, and the
|
|
5
|
+
* guard fails until it has a surface. No static list to keep in sync (that drift
|
|
6
|
+
* is the whole bug the guard exists to prevent).
|
|
7
|
+
*
|
|
8
|
+
* Covers the App Router (`app/`, `src/app/` — directories with a `page.*`) and the
|
|
9
|
+
* Pages Router (`pages/`, `src/pages/` — page files, minus `_app`/`_document`/`api`).
|
|
10
|
+
* Route groups `(group)` and parallel slots `@slot` are stripped; `[param]` /
|
|
11
|
+
* `[...catchall]` segments mark a route dynamic. It reads the filesystem only —
|
|
12
|
+
* no framework internals — so it's a heuristic, not a router; edit the generated
|
|
13
|
+
* spec if your routing does something exotic.
|
|
14
|
+
*/
|
|
15
|
+
export type DiscoveredRoute = {
|
|
16
|
+
/** Filename-safe surface key (`/` → `index`, `/blog/[slug]` → `blog-slug`). */
|
|
17
|
+
key: string;
|
|
18
|
+
/** URL path to navigate (`/`, `/about`, `/blog/[slug]`). */
|
|
19
|
+
path: string;
|
|
20
|
+
/** True when a segment is a `[param]` / `[...catchall]` — can't be navigated as-is. */
|
|
21
|
+
dynamic: boolean;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Discover a Next.js project's routes under `cwd` (default `process.cwd()`).
|
|
25
|
+
* Returns one entry per route, deduped by path (App Router wins a tie) and sorted.
|
|
26
|
+
* Empty array when no `app/` or `pages/` dir is found — the caller decides whether
|
|
27
|
+
* that means "not a Next project".
|
|
28
|
+
*/
|
|
29
|
+
export declare function discoverNextRoutes(cwd?: string): DiscoveredRoute[];
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const APP_PAGE_RE = /^page\.(?:js|jsx|ts|tsx|mdx)$/;
|
|
4
|
+
const PAGES_EXT_RE = /\.(?:js|jsx|ts|tsx)$/;
|
|
5
|
+
const isGroupOrSlot = (seg) => /^\(.*\)$/.test(seg) || seg.startsWith('@');
|
|
6
|
+
const isDynamicSeg = (seg) => /\[.*\]/.test(seg);
|
|
7
|
+
/** Filename-safe, readable key from a route path. */
|
|
8
|
+
function toKey(routePath) {
|
|
9
|
+
if (routePath === '/')
|
|
10
|
+
return 'index';
|
|
11
|
+
const k = routePath
|
|
12
|
+
.replace(/^\//, '')
|
|
13
|
+
.replace(/\[\[?\.\.\.([^\]]+)\]\]?/g, 'all-$1') // [...x] / [[...x]] → all-x
|
|
14
|
+
.replace(/\[([^\]]+)\]/g, '$1') // [x] → x
|
|
15
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
16
|
+
.replace(/^-+|-+$/g, '')
|
|
17
|
+
.toLowerCase();
|
|
18
|
+
return k || 'index';
|
|
19
|
+
}
|
|
20
|
+
function makeRoute(segs) {
|
|
21
|
+
const routePath = segs.length ? '/' + segs.join('/') : '/';
|
|
22
|
+
return { key: toKey(routePath), path: routePath, dynamic: segs.some(isDynamicSeg) };
|
|
23
|
+
}
|
|
24
|
+
function readDir(dir) {
|
|
25
|
+
try {
|
|
26
|
+
return fs.readdirSync(dir, { withFileTypes: true });
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return []; // missing/unreadable dir → no routes, never throw into a spec
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** App Router: a directory holding a `page.*` is a route; nest by sub-directory. */
|
|
33
|
+
function appRoutes(appDir) {
|
|
34
|
+
const out = [];
|
|
35
|
+
const walk = (dir, segs) => {
|
|
36
|
+
const entries = readDir(dir);
|
|
37
|
+
if (entries.some((e) => e.isFile() && APP_PAGE_RE.test(e.name))) {
|
|
38
|
+
out.push(makeRoute(segs.filter((s) => !isGroupOrSlot(s))));
|
|
39
|
+
}
|
|
40
|
+
for (const e of entries)
|
|
41
|
+
if (e.isDirectory())
|
|
42
|
+
walk(path.join(dir, e.name), [...segs, e.name]);
|
|
43
|
+
};
|
|
44
|
+
walk(appDir, []);
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
/** Pages Router: each page file is a route; `index` collapses to its directory. */
|
|
48
|
+
function pagesRoutes(pagesDir) {
|
|
49
|
+
const out = [];
|
|
50
|
+
const walk = (dir, segs) => {
|
|
51
|
+
for (const e of readDir(dir)) {
|
|
52
|
+
if (e.isDirectory()) {
|
|
53
|
+
if (segs.length === 0 && e.name === 'api')
|
|
54
|
+
continue; // API routes aren't pages
|
|
55
|
+
walk(path.join(dir, e.name), [...segs, e.name]);
|
|
56
|
+
}
|
|
57
|
+
else if (e.isFile() && PAGES_EXT_RE.test(e.name)) {
|
|
58
|
+
const base = e.name.replace(PAGES_EXT_RE, '');
|
|
59
|
+
if (base.startsWith('_'))
|
|
60
|
+
continue; // _app, _document, _error
|
|
61
|
+
out.push(makeRoute(base === 'index' ? segs : [...segs, base]));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
walk(pagesDir, []);
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
const firstExisting = (cwd, candidates) => candidates.map((d) => path.join(cwd, d)).find((p) => fs.existsSync(p));
|
|
69
|
+
/**
|
|
70
|
+
* Discover a Next.js project's routes under `cwd` (default `process.cwd()`).
|
|
71
|
+
* Returns one entry per route, deduped by path (App Router wins a tie) and sorted.
|
|
72
|
+
* Empty array when no `app/` or `pages/` dir is found — the caller decides whether
|
|
73
|
+
* that means "not a Next project".
|
|
74
|
+
*/
|
|
75
|
+
export function discoverNextRoutes(cwd = process.cwd()) {
|
|
76
|
+
const appDir = firstExisting(cwd, ['app', 'src/app']);
|
|
77
|
+
const pagesDir = firstExisting(cwd, ['pages', 'src/pages']);
|
|
78
|
+
const found = [...(appDir ? appRoutes(appDir) : []), ...(pagesDir ? pagesRoutes(pagesDir) : [])];
|
|
79
|
+
const byPath = new Map();
|
|
80
|
+
for (const r of found)
|
|
81
|
+
if (!byPath.has(r.path))
|
|
82
|
+
byPath.set(r.path, r);
|
|
83
|
+
return [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
84
|
+
}
|
package/dist/runner.d.ts
CHANGED
|
@@ -7,7 +7,11 @@ import type { Page } from '@playwright/test';
|
|
|
7
7
|
export type Surface = {
|
|
8
8
|
/** Capture file name prefix; must be unique. */
|
|
9
9
|
key: string;
|
|
10
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* Navigate and drive the page to the state. Only reach the state — StyleProof
|
|
12
|
+
* settles it for you (waits out in-flight data and fonts, freezes animations)
|
|
13
|
+
* before reading, so you don't hand-roll `networkidle`/`fonts.ready` waits here.
|
|
14
|
+
*/
|
|
11
15
|
go: (page: Page) => Promise<void>;
|
|
12
16
|
/** Selectors for nondeterministic regions (live data, third-party embeds); skipped entirely. */
|
|
13
17
|
ignore?: string[];
|
|
@@ -18,6 +22,23 @@ export type Surface = {
|
|
|
18
22
|
};
|
|
19
23
|
export type DefineOptions = {
|
|
20
24
|
surfaces: Surface[];
|
|
25
|
+
/**
|
|
26
|
+
* The full set of surface keys the app knows it has — its route/view universe,
|
|
27
|
+
* typically derived from a registry (e.g. an app's list of routes or view ids).
|
|
28
|
+
* When set, StyleProof emits a coverage-guard test (in the NORMAL suite, not
|
|
29
|
+
* gated on a capture dir) that fails if any expected key is neither captured (a
|
|
30
|
+
* surface) nor in `exclude`. This is what stops a newly added route from
|
|
31
|
+
* shipping uncaptured: the gate can only diff what a spec lists, so without this
|
|
32
|
+
* a forgotten surface is silently invisible. Omit to opt out (no guard).
|
|
33
|
+
*/
|
|
34
|
+
expected?: string[];
|
|
35
|
+
/**
|
|
36
|
+
* Expected keys deliberately NOT captured, each mapped to the reason — a visible,
|
|
37
|
+
* reviewed opt-out ledger. Keeps the coverage guard green for known gaps without
|
|
38
|
+
* letting them hide: an entry whose key isn't in `expected` (a renamed/removed
|
|
39
|
+
* route) also fails the guard, so the ledger can't rot.
|
|
40
|
+
*/
|
|
41
|
+
exclude?: Record<string, string>;
|
|
21
42
|
/**
|
|
22
43
|
* Output directory label. Convention: drive it from an env var so the same
|
|
23
44
|
* spec captures `before`, `after`, or a CI label — and skips entirely when
|
|
@@ -58,10 +79,16 @@ export type DefineOptions = {
|
|
|
58
79
|
/** Fixed instant for the frozen clock (default `2025-01-01T00:00:00Z`). */
|
|
59
80
|
clockTime?: string | number | Date;
|
|
60
81
|
/**
|
|
61
|
-
* Capture each surface twice and fail if the computed styles differ — proves
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
82
|
+
* Capture each surface twice and fail if the computed styles differ — proves the
|
|
83
|
+
* capture is deterministic (catches a replay gap falling through to the live
|
|
84
|
+
* backend, or unseeded client randomness) instead of letting it surface as a
|
|
85
|
+
* phantom change on an unrelated diff.
|
|
86
|
+
*
|
|
87
|
+
* Defaults ON for the RECORDING run and OFF for the REPLAY run: live nondeterminism
|
|
88
|
+
* surfaces while recording against the real backend, whereas the replay run renders
|
|
89
|
+
* against the recorded HAR and is deterministic by construction — so self-checking it
|
|
90
|
+
* just doubles the work. `STYLEPROOF_SELFCHECK=1` forces it on for both; pass
|
|
91
|
+
* `selfCheck` explicitly to override.
|
|
65
92
|
*/
|
|
66
93
|
selfCheck?: boolean;
|
|
67
94
|
/**
|
|
@@ -87,6 +114,14 @@ export type DefineOptions = {
|
|
|
87
114
|
* recently added route first); non-stream requests `fallback()` to the HAR.
|
|
88
115
|
*/
|
|
89
116
|
export declare function passLiveStreams(page: Page, url: string): Promise<void>;
|
|
117
|
+
/**
|
|
118
|
+
* Default for `selfCheck` when the consumer didn't set it: ON when RECORDING (no
|
|
119
|
+
* `replayFrom`) — that's where live nondeterminism surfaces — and OFF when REPLAYING,
|
|
120
|
+
* since the replay run renders against the recorded HAR and is deterministic by
|
|
121
|
+
* construction, so self-checking it just doubles the work. `STYLEPROOF_SELFCHECK=1`
|
|
122
|
+
* forces it on either way.
|
|
123
|
+
*/
|
|
124
|
+
export declare function defaultSelfCheck(replayFrom: string | undefined, env?: string | undefined): boolean;
|
|
90
125
|
/**
|
|
91
126
|
* Generate one Playwright test per surface × width that captures the style
|
|
92
127
|
* map to `<baseDir>/<dir>/<key>@<width>.json.gz`. Captures are made
|
|
@@ -100,4 +135,4 @@ export declare function passLiveStreams(page: Page, url: string): Promise<void>;
|
|
|
100
135
|
* defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
|
|
101
136
|
* ```
|
|
102
137
|
*/
|
|
103
|
-
export declare function defineStyleMapCapture({ surfaces, dir, baseDir, screenshots, replayFrom, replayUrl, freezeClock, clockTime, selfCheck, captureText, }: DefineOptions): void;
|
|
138
|
+
export declare function defineStyleMapCapture({ surfaces, expected, exclude, dir, baseDir, screenshots, replayFrom, replayUrl, freezeClock, clockTime, selfCheck, captureText, }: DefineOptions): void;
|
package/dist/runner.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { test } from '@playwright/test';
|
|
1
|
+
import { test, expect } from '@playwright/test';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { captureStyleMap, saveStyleMap } from './capture.js';
|
|
4
|
+
import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
|
|
5
5
|
import { diffStyleMaps } from './diff.js';
|
|
6
|
+
import { coverageGaps } from './coverage.js';
|
|
6
7
|
/** One-line description of the first drift finding, for the self-check error. */
|
|
7
8
|
function driftDesc(f) {
|
|
8
9
|
if (f.kind === 'dom')
|
|
@@ -69,9 +70,9 @@ async function pinInputs(page, harName, s) {
|
|
|
69
70
|
await page.clock.setFixedTime(new Date(s.clockTime));
|
|
70
71
|
}
|
|
71
72
|
/** Capture the surface again and throw if the computed styles drifted from `first`. */
|
|
72
|
-
async function assertDeterministic(page, surface, first, captureText) {
|
|
73
|
+
async function assertDeterministic(page, surface, first, captureText, pending) {
|
|
73
74
|
await surface.go(page);
|
|
74
|
-
const again = await captureStyleMap(page, { ignore: surface.ignore ?? [], captureText });
|
|
75
|
+
const again = await captureStyleMap(page, { ignore: surface.ignore ?? [], captureText, pendingRequests: pending });
|
|
75
76
|
const drift = diffStyleMaps(first, again);
|
|
76
77
|
if (drift.length) {
|
|
77
78
|
throw new Error(`styleproof self-check failed: ${surface.key} is non-deterministic — ` +
|
|
@@ -86,18 +87,41 @@ async function captureSurface(page, surface, width, s) {
|
|
|
86
87
|
await pinInputs(page, `${surface.key}@${width}.har`, s);
|
|
87
88
|
const height = typeof surface.height === 'function' ? surface.height(width) : (surface.height ?? 800);
|
|
88
89
|
await page.setViewportSize({ width, height });
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
90
|
+
// Arm the in-flight request tracker BEFORE go() so the surface's own load fetches
|
|
91
|
+
// count toward the network-aware settle — a request that starts during navigation
|
|
92
|
+
// fired its event before captureStyleMap could attach a listener of its own.
|
|
93
|
+
const requests = trackInflightRequests(page);
|
|
94
|
+
try {
|
|
95
|
+
await surface.go(page);
|
|
96
|
+
const map = await captureStyleMap(page, {
|
|
97
|
+
ignore: surface.ignore ?? [],
|
|
98
|
+
captureText: s.captureText,
|
|
99
|
+
pendingRequests: requests.pending,
|
|
100
|
+
});
|
|
101
|
+
if (s.selfCheck)
|
|
102
|
+
await assertDeterministic(page, surface, map, s.captureText, requests.pending);
|
|
103
|
+
const stem = path.join(s.baseDir, s.dir, `${surface.key}@${width}`);
|
|
104
|
+
saveStyleMap(`${stem}.json.gz`, map);
|
|
105
|
+
if (s.screenshots) {
|
|
106
|
+
// captureStyleMap froze animations/transitions, so this is the same settled
|
|
107
|
+
// state the map describes.
|
|
108
|
+
await page.screenshot({ path: `${stem}.png`, fullPage: true, animations: 'disabled' });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
requests.dispose();
|
|
99
113
|
}
|
|
100
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Default for `selfCheck` when the consumer didn't set it: ON when RECORDING (no
|
|
117
|
+
* `replayFrom`) — that's where live nondeterminism surfaces — and OFF when REPLAYING,
|
|
118
|
+
* since the replay run renders against the recorded HAR and is deterministic by
|
|
119
|
+
* construction, so self-checking it just doubles the work. `STYLEPROOF_SELFCHECK=1`
|
|
120
|
+
* forces it on either way.
|
|
121
|
+
*/
|
|
122
|
+
export function defaultSelfCheck(replayFrom, env = process.env.STYLEPROOF_SELFCHECK) {
|
|
123
|
+
return env === '1' || !replayFrom;
|
|
124
|
+
}
|
|
101
125
|
/**
|
|
102
126
|
* Generate one Playwright test per surface × width that captures the style
|
|
103
127
|
* map to `<baseDir>/<dir>/<key>@<width>.json.gz`. Captures are made
|
|
@@ -111,8 +135,7 @@ async function captureSurface(page, surface, width, s) {
|
|
|
111
135
|
* defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
|
|
112
136
|
* ```
|
|
113
137
|
*/
|
|
114
|
-
export function defineStyleMapCapture({ surfaces, dir, baseDir = '__stylemaps__', screenshots = true, replayFrom = process.env.STYLEPROOF_REPLAY_FROM, replayUrl = process.env.STYLEPROOF_REPLAY_URL ?? '**/api/**', freezeClock = true, clockTime = '2025-01-01T00:00:00Z', selfCheck =
|
|
115
|
-
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
138
|
+
export function defineStyleMapCapture({ surfaces, expected, exclude = {}, dir, baseDir = '__stylemaps__', screenshots = true, replayFrom = process.env.STYLEPROOF_REPLAY_FROM, replayUrl = process.env.STYLEPROOF_REPLAY_URL ?? '**/api/**', freezeClock = true, clockTime = '2025-01-01T00:00:00Z', selfCheck = defaultSelfCheck(replayFrom), captureText = false, }) {
|
|
116
139
|
const settings = {
|
|
117
140
|
dir: dir,
|
|
118
141
|
baseDir,
|
|
@@ -124,7 +147,25 @@ export function defineStyleMapCapture({ surfaces, dir, baseDir = '__stylemaps__'
|
|
|
124
147
|
selfCheck,
|
|
125
148
|
captureText,
|
|
126
149
|
};
|
|
150
|
+
// Coverage guard. Runs in the NORMAL test suite (NOT gated on a capture dir), so
|
|
151
|
+
// a route added without a surface fails the app's own tests — long before, and
|
|
152
|
+
// independent of, a capture run. This is the one gap captures can't catch: a
|
|
153
|
+
// surface never taken can't be diffed. Only emitted when the spec declares its
|
|
154
|
+
// `expected` universe; otherwise StyleProof keeps its prior behaviour exactly.
|
|
155
|
+
if (expected) {
|
|
156
|
+
test.describe('styleproof coverage', () => {
|
|
157
|
+
test('every expected surface is captured or explicitly excluded', () => {
|
|
158
|
+
const { uncovered, staleExclusions } = coverageGaps(surfaces.map((s) => s.key), expected, exclude);
|
|
159
|
+
expect(uncovered, `StyleProof coverage gap: ${uncovered.length} expected surface(s) are neither captured ` +
|
|
160
|
+
`nor excluded — add each to \`surfaces\`, or to \`exclude\` with a reason. ` +
|
|
161
|
+
`Missing: ${uncovered.join(', ')}`).toEqual([]);
|
|
162
|
+
expect(staleExclusions, `StyleProof: \`exclude\` lists surface(s) absent from \`expected\` ` +
|
|
163
|
+
`(renamed or removed?): ${staleExclusions.join(', ')}`).toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
}
|
|
127
167
|
test.describe('styleproof capture', () => {
|
|
168
|
+
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
128
169
|
for (const surface of surfaces) {
|
|
129
170
|
for (const width of surface.widths) {
|
|
130
171
|
test(`${surface.key} @ ${width}`, ({ page }) => captureSurface(page, surface, width, settings));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Catch every CSS change before it ships — review PRs and certify refactors by the browser's computed styles, not pixels. Works with any styling system.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playwright",
|