styleproof 3.2.0 → 3.4.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 +91 -0
- package/README.md +79 -5
- package/bin/styleproof-capture.mjs +148 -0
- package/bin/styleproof-diff.mjs +1 -1
- package/bin/styleproof-report.mjs +3 -3
- package/dist/capture-url.d.ts +97 -0
- package/dist/capture-url.js +234 -0
- package/dist/crawl-surfaces.d.ts +122 -0
- package/dist/crawl-surfaces.js +668 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/report.js +5 -7
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,90 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.4.0] - 2026-07-02
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Consuming actions (controls that disappear when used — resolve/approve/dismiss
|
|
15
|
+
rows) no longer spawn a combinatorial decision lattice. Their result-states are
|
|
16
|
+
captured and swept RETRY-ONLY: the parent's persistent mode-switchers still
|
|
17
|
+
apply (a resolved list's tab view stays reachable), but no fresh candidates are
|
|
18
|
+
collected there — removed rows shift sibling nth-of-type selectors, which made
|
|
19
|
+
the same logical controls look "fresh" in every decided subset and could stall
|
|
20
|
+
an exhaustive crawl for hours adding zero coverage. Found live, on a
|
|
21
|
+
decision-heavy page whose crawl went 449 surfaces / 6 new classes before the
|
|
22
|
+
rule; with it, the same shape converges in single digits with full coverage.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- `styleproof-capture --setup <file>`: deterministic steps (goto/fill/click/
|
|
27
|
+
waitFor) run after every fresh navigation, so input-gated states — a login,
|
|
28
|
+
an unlock code, seeded input — become crawlable. `${ENV_VAR}` in values is
|
|
29
|
+
interpolated from the environment at load time, so credentials never live in
|
|
30
|
+
the file or the maps; a failed non-optional step aborts loudly.
|
|
31
|
+
- Automatic data states: the crawl watches the entry page's data requests and
|
|
32
|
+
additionally captures `loading` (requests stalled — the skeleton) and `error`
|
|
33
|
+
(requests fulfilled with 500) out of the box. Identical-to-base renders dedup
|
|
34
|
+
away; `--no-data-states` to skip.
|
|
35
|
+
- README: "What the crawler can and cannot reach — honestly" — the crawl
|
|
36
|
+
vocabulary, what each state class is reached by, and the verifier contract
|
|
37
|
+
that anything unreached is named, never silently missed.
|
|
38
|
+
- Automatic neutral-input fill: text/search/email/tel/url/number inputs and
|
|
39
|
+
textareas are typed with a deterministic value, so input-driven states (a
|
|
40
|
+
search's results, a filter's rendering) are crawled with no config.
|
|
41
|
+
Credential-semantic fields (type=password, autocomplete username/
|
|
42
|
+
current-password/new-password/one-time-code) are never auto-filled — that is
|
|
43
|
+
`--setup` territory, the only input the tool cannot derive.
|
|
44
|
+
- Automatic scroll reveal: a bounded, deterministic scroll pass on every load
|
|
45
|
+
mounts IntersectionObserver/lazy content before discovery, so scroll-gated
|
|
46
|
+
sections are part of the mapped surface.
|
|
47
|
+
- The crawl now auto-detects the page's real `@media` breakpoints when no
|
|
48
|
+
`--widths` are given (like the one-shot path), sweeping one width per band.
|
|
49
|
+
- `--setup` steps are honoured by the one-shot (non-crawl) capture too, so a
|
|
50
|
+
gated page's single state is capturable directly.
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- Automatic data states now intercept by resource type (fetch/XHR) instead of
|
|
55
|
+
exact URL — apps that cache-bust their requests (`?t=...`) previously
|
|
56
|
+
slipped past the stall and produced no `loading` capture.
|
|
57
|
+
|
|
58
|
+
## [3.3.0] - 2026-07-02
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- New `styleproof-capture <url>` CLI: capture a single page (a deployed URL, a
|
|
63
|
+
static export, or a standalone HTML mockup) so you can prove a production build
|
|
64
|
+
renders identically to its design. Point it at the design and at the build,
|
|
65
|
+
diff the two, and zero diff means pixel-identical — anything else is named down
|
|
66
|
+
to the computed style. One shot, no spec, no config; writes the same
|
|
67
|
+
`<key>@<width>.json.gz` (+ `.png`) shape any capture does, so `styleproof-diff`
|
|
68
|
+
compares it against anything. Also exported programmatically as
|
|
69
|
+
`captureUrlToDir` / `runCaptureUrl` / `parseCaptureUrlArgs`.
|
|
70
|
+
- `styleproof-capture --crawl`: map a whole interactive design from one URL with
|
|
71
|
+
no spec and no selectors — EXHAUSTIVE by default, running until every
|
|
72
|
+
non-destructive control has been driven once and every structurally new surface
|
|
73
|
+
captured (a modal's tabs, a drawer's sub-views, a popover's panels), each under
|
|
74
|
+
a derived key. The sweep works in place with lazy, fingerprint-verified resets:
|
|
75
|
+
a no-op click costs nothing, only state-changing clicks pay a fresh
|
|
76
|
+
navigation + replay, and children are never attributed to the wrong parent.
|
|
77
|
+
Deterministic, deduped by a structural fingerprint, self-settling for async
|
|
78
|
+
(React/Vue/Babel) apps, discovery pinned to one viewport width, progress
|
|
79
|
+
streamed per captured surface, and destructive-looking controls are never
|
|
80
|
+
clicked. `--max-depth` / `--max-actions` / `--max-states` exist only as
|
|
81
|
+
throttles. Exported programmatically as `crawlAndCapture`.
|
|
82
|
+
- Crawl coverage verifier: after `--crawl`, every class the page's own
|
|
83
|
+
stylesheets define (parsed CSSOM) is checked against the classes rendered
|
|
84
|
+
across all captured surfaces, and the never-seen residue is printed — dead
|
|
85
|
+
CSS, or a state the crawl could not reach. `--require-full-coverage` makes
|
|
86
|
+
any residue exit 4, so "the design is fully covered, nothing missing" is a
|
|
87
|
+
machine-checked property. On `CrawlReport.coverage` programmatically.
|
|
88
|
+
|
|
89
|
+
### Fixed
|
|
90
|
+
|
|
91
|
+
- GitHub Action reports now publish every generated crop PNG, including zoom
|
|
92
|
+
crops, so committed `report.md` files do not point at missing images.
|
|
93
|
+
|
|
10
94
|
## [3.2.0] - 2026-06-30
|
|
11
95
|
|
|
12
96
|
### Added
|
|
@@ -72,6 +156,13 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
72
156
|
|
|
73
157
|
## [3.1.4] - 2026-06-29
|
|
74
158
|
|
|
159
|
+
### Changed
|
|
160
|
+
|
|
161
|
+
- New surfaces now require approval in review-gate mode. A new-surface-only diff
|
|
162
|
+
still exits `3`, but the Action marks `changed=true`, posts the report, and
|
|
163
|
+
holds the `StyleProof` status red until the reviewer approves it. Certify mode
|
|
164
|
+
was already strict because it fails on any report.
|
|
165
|
+
|
|
75
166
|
### Fixed
|
|
76
167
|
|
|
77
168
|
- **Popup capture no longer collapses distinct triggers that reuse the same
|
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ report. Intentional visual changes get approved; unexpected ones block or fail.
|
|
|
21
21
|
- [Works with any styling system](#works-with-any-styling-system)
|
|
22
22
|
- [Breakpoints, detected automatically](#breakpoints-detected-automatically)
|
|
23
23
|
- [Certify a refactor](#certify-a-refactor)
|
|
24
|
+
- [Match a design pixel-for-pixel](#match-a-design-pixel-for-pixel)
|
|
24
25
|
- [Install](#install)
|
|
25
26
|
- [Quickstart](#quickstart)
|
|
26
27
|
- [Forks and Dependabot](#forks-and-dependabot)
|
|
@@ -52,7 +53,9 @@ The important boundary: StyleProof only certifies states it can reach. If a stat
|
|
|
52
53
|
matters, list it as a surface, variant, popup, live state, or component-catalog
|
|
53
54
|
surface. `expected` turns that inventory into a guard, so an uncaptured new page,
|
|
54
55
|
component, modal, dropdown, or toast fails as missing coverage instead of
|
|
55
|
-
silently passing.
|
|
56
|
+
silently passing. A surface that exists only on the PR head is still reviewable:
|
|
57
|
+
in review-gate mode it holds the status red until approved, then becomes part of
|
|
58
|
+
the baseline once merged.
|
|
56
59
|
|
|
57
60
|
## The short version
|
|
58
61
|
|
|
@@ -117,8 +120,8 @@ full report. The report groups each distinct visual change with:
|
|
|
117
120
|
|
|
118
121
|
In review-gate mode, one **Approve all changes** checkbox turns the `StyleProof`
|
|
119
122
|
status green for that commit. Clean runs still leave a receipt: `No visual
|
|
120
|
-
changes detected.` New surfaces are shown as new baselines
|
|
121
|
-
handled by `expected`.
|
|
123
|
+
changes detected.` New surfaces are shown as new baselines and require approval;
|
|
124
|
+
coverage gaps are handled by `expected`.
|
|
122
125
|
|
|
123
126
|
## Auto-discovery
|
|
124
127
|
|
|
@@ -397,6 +400,77 @@ Omit `widths` on a surface and StyleProof reads your app's real `@media` breakpo
|
|
|
397
400
|
|
|
398
401
|
The same engine has a second mode that proves a change touched _nothing_ visual: with `fail-on-diff: true`, any difference at all fails the job. It's the job StyleProof was born for — certifying a CSS-to-Tailwind migration rendered byte-for-byte identical. Reach for it on any change whose whole promise is "the output is unchanged": a utility-class migration, a design-system swap, a dependency or build-tooling bump. Zero diff is the contract; one drifting longhand is a regression to investigate, not a change to approve.
|
|
399
402
|
|
|
403
|
+
## Match a design pixel-for-pixel
|
|
404
|
+
|
|
405
|
+
When you build a design in production, "looks the same" is a judgement call — and small gaps ship. `styleproof-capture` makes it an objective check: point it at the **design** (a deployed mockup, a static export, a standalone HTML file), point it at your **build**, and diff. Zero diff means the production UI renders _identically_ to the design; anything else is named exactly, down to the computed style, so you know precisely what's still off.
|
|
406
|
+
|
|
407
|
+
```bash
|
|
408
|
+
styleproof-capture https://example.com/pricing --key pricing --widths 1440,1024,768 --out design
|
|
409
|
+
styleproof-diff design .styleproof/maps/current # design vs build — zero diff = pixel-identical
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
You watch one number as you implement: the diff starts large and shrinks toward zero, and it hits zero the moment the built page matches the design. It's the objective version of putting the mockup and the app side by side and squinting.
|
|
413
|
+
|
|
414
|
+
(`styleproof-map` is the spec-driven flow for your own app's surfaces, with the coverage guard, map store, and record/replay; `styleproof-capture` is the one-shot for a page you just point at.) It writes `design/pricing@1440.json.gz` (+ `.png`), the same shape any capture writes, so `styleproof-diff` compares it against anything. Omit `--widths` to auto-detect the page's own `@media` breakpoints; pin them for a page whose CSS is cross-origin (a font stylesheet, say), since detection reads every sheet and fails loudly rather than guess. `--wait <selector>` holds until the intended state is on screen; `--ignore <selector>` skips a live region. Capture both sides in the same browser + fonts, since that's what "identical" is measured against.
|
|
415
|
+
|
|
416
|
+
### Crawl the whole interactive design: `--crawl`
|
|
417
|
+
|
|
418
|
+
A design is mostly _behind clicks_ — modals, drawers, popovers, tabs that don't exist in the DOM until you open them. A single capture sees only the landing state. `--crawl` maps the rest for you: point it at the URL and it drives every non-destructive control, keeps whatever opens a structurally new surface, and recurses into it — a modal's tabs, a drawer's sub-views, a popover's panels — capturing each under a derived key. No spec, no selectors, no hand-holding.
|
|
419
|
+
|
|
420
|
+
```bash
|
|
421
|
+
styleproof-capture https://example.com --crawl --out design # maps every reachable surface
|
|
422
|
+
styleproof-diff design .styleproof/maps/current # diff the whole surface vs your build
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
It's **exhaustive by default**: the crawl stops when there is nothing left to drive — every control tried once, every structurally new surface captured — not at a budget. Termination is guaranteed by dedup (controls dedup by selector, surfaces by a structural fingerprint), and the `--max-depth` / `--max-actions` / `--max-states` flags exist only as deliberate throttles. It's deterministic (document order; the same surface reached two ways is captured once) and self-settling — it waits for an async app (React/Vue/Babel that boots after `load`) to mount before reading, so a bare crawl of a client-rendered page still captures the mounted UI.
|
|
426
|
+
|
|
427
|
+
What makes exhaustive affordable is that the sweep works **in place**: standing in a state, each control is clicked right where the page is, and a cheap DOM fingerprint decides what happened — a no-op click costs nothing, and only a state-changing click pays a reset (fresh navigation + replay of the click-path), which is then **verified by fingerprint** so children are never attributed to the wrong parent. New surfaces are captured at every width the moment they're reached — a deep or animated click-path is never re-driven to capture, so it can't be the thing that drops a surface. Progress streams as it goes, one line per captured surface.
|
|
428
|
+
|
|
429
|
+
**And it proves nothing was missed.** After the crawl, StyleProof compares every class the page's own stylesheets define (read from the parsed CSSOM) against the classes actually rendered across the captured surfaces, and prints what — if anything — was never seen. `--require-full-coverage` turns any residue into exit code 4, so "the design is fully covered" is a CI-checkable property, not a judgement call. What's left is either dead CSS (delete it) or a state the crawl couldn't reach (drive it with a spec, or file the gap).
|
|
430
|
+
|
|
431
|
+
**Destructive-looking controls (delete, deploy, pay, revoke…) are never clicked** — mapping must not mutate; states gated behind one of those need a spec. Prefer the spec-driven `defineStyleMapCapture` when you want stable, named keys and the coverage guard; reach for `--crawl` to map a design (or a third-party page) you don't have a spec for.
|
|
432
|
+
|
|
433
|
+
### Data states, out of the box
|
|
434
|
+
|
|
435
|
+
Every data-driven page has states that almost never sit on a click path: the **loading skeleton** and the **error render**. The crawl captures both automatically — it watches the entry page's data requests, then re-loads once with them **stalled** (the skeleton is the settled state, captured as `loading`) and once with them **fulfilled as 500** (captured as `error`). States that render identically to the base (e.g. server-rendered pages) dedup away silently. On by default; `--no-data-states` to skip. Deeper data states — a specific empty list, a partial payload — are fixture territory: model them as `liveStates`/`variants` in a spec.
|
|
436
|
+
|
|
437
|
+
### Input-gated states: `--setup`
|
|
438
|
+
|
|
439
|
+
A crawler clicks and selects; it does not guess your password. States behind typed input — a login, an unlock code, a seeded search — become crawlable with a deterministic setup file, run after **every** fresh navigation so each reset re-establishes the gate identically:
|
|
440
|
+
|
|
441
|
+
```json
|
|
442
|
+
[
|
|
443
|
+
{ "action": "fill", "selector": "#user", "value": "${CAPTURE_USER}" },
|
|
444
|
+
{ "action": "fill", "selector": "#pass", "value": "${CAPTURE_PASS}" },
|
|
445
|
+
{ "action": "click", "selector": "#sign-in" },
|
|
446
|
+
{ "action": "waitFor", "selector": ".dashboard" }
|
|
447
|
+
]
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
```bash
|
|
451
|
+
CAPTURE_USER=demo CAPTURE_PASS=… styleproof-capture https://example.com --crawl --setup login.json --out design
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
`${ENV_VAR}` in `value`/`url` is interpolated from the environment at load time — **credentials never live in the file, the shell history, or the captured maps.** A non-optional step that fails aborts the crawl loudly (a half-established gate must never silently crawl the ungated page); mark a step `"optional": true` when it legitimately may not apply (a cookie-session app that shows the login form only once).
|
|
455
|
+
|
|
456
|
+
### What the crawler can and cannot reach — honestly
|
|
457
|
+
|
|
458
|
+
The crawl's vocabulary is **click, select, neutral typing, scrolling, and your setup steps** — and it sweeps the page's real `@media` breakpoints automatically when you give it none. Within it, mapping is exhaustive. Outside it, states are not reached by crawling — and the coverage verifier is what keeps that honest: anything unreached is _named_, never silently missed.
|
|
459
|
+
|
|
460
|
+
| State | Reached by |
|
|
461
|
+
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
462
|
+
| Click-opened surfaces (modals, drawers, popovers, tabs, toggles) | crawl, automatically |
|
|
463
|
+
| Mode × sibling combinations (a tab's edit state, a decided list's other tab) | crawl — family retry |
|
|
464
|
+
| Loading / error data states of the entry page | crawl — automatic data states |
|
|
465
|
+
| Login / unlock / typed input | `--setup` steps |
|
|
466
|
+
| `:hover` / `:focus` / `:active` styling | the forced-state layer of every capture |
|
|
467
|
+
| Deeper data states (empty, partial, streaming) | spec `liveStates` / `variants` with fixtures |
|
|
468
|
+
| States behind destructive actions | a spec, deliberately — the crawl never clicks them |
|
|
469
|
+
| Drag-and-drop, keyboard-shortcut, scroll-triggered states | a spec driving them explicitly |
|
|
470
|
+
| Components not mounted anywhere in the UI | a component catalog page (each component per prop-state is a surface — Storybook/Ladle stories work; `discoverComponentFiles` fails CI when a component file has no captured surface) |
|
|
471
|
+
|
|
472
|
+
The rule of thumb: **a rendered state is a function of props, data, and input.** Control all three — mock the data, script the input, mount the component — and every state a component can render is a capturable surface. The verifier tells you, by name, which ones you haven't controlled yet.
|
|
473
|
+
|
|
400
474
|
## Install
|
|
401
475
|
|
|
402
476
|
```bash
|
|
@@ -620,7 +694,7 @@ When a PR **adds** an element, StyleProof now reports its **full resting compute
|
|
|
620
694
|
| `fail-on-diff` | `true` | Certify mode: fail on any diff. Ignored when `require-approval` is true. |
|
|
621
695
|
| `status-context` | `StyleProof` | Commit-status name. Must match the approve workflow and branch protection. |
|
|
622
696
|
|
|
623
|
-
Outputs: `changed` (`"true"` when
|
|
697
|
+
Outputs: `changed` (`"true"` when any existing surface changed, or a new surface needs approval), `report-url`. Other inputs (`report-branch`, `github-token`) have sensible defaults — see [`action.yml`](https://github.com/BenSheridanEdwards/StyleProof/blob/main/action.yml).
|
|
624
698
|
|
|
625
699
|
**Policy file `styleproof.config.json`** (optional, at the repo root) — gate policy that isn't workflow plumbing:
|
|
626
700
|
|
|
@@ -679,7 +753,7 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
|
|
|
679
753
|
|
|
680
754
|
- `styleproof-init` — scaffold the gate: the capture spec, a dedicated `playwright.styleproof.config.ts` (production-build `webServer`, parallel capture), `.gitignore` cache entries, and the cache-first report workflow. One command. Generated commands follow the repo's lockfile (`bun.lock`/`bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, or npm by default), respect pnpm/Corepack version pins, and detect Vite/Next production preview commands instead of assuming every repo has `start`.
|
|
681
755
|
- `styleproof-map` — capture the current commit's computed-style map through Playwright. By default it writes `.styleproof/maps/current`, keeps screenshots for reports, writes a manifest, and uploads to `styleproof-maps` outside CI when the working tree was clean and a git remote exists. Pass `--crawl-base-url` plus repeated `--crawl-route` to run `styleproof-variants` before capture, `--no-upload`, `--restore --sha <commit>`, `--spec`, `--dir`, `--base-dir`, or `--no-screenshots` for custom flows.
|
|
682
|
-
- `styleproof-diff` — the certify gate. With no args, it restores cached maps for the current commit and inferred base (`GITHUB_BASE_REF`, `branch.<name>.gh-merge-base`, `gh pr view`, then main/master fallbacks); `styleproof-diff main` / `styleproof-diff master` pins the base; `styleproof-diff <beforeDir> <afterDir>` keeps the manual two-directory form for CI fallback captures. 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). A clean run prints `0 changed surfaces across N captured surface(s)`, and `--json` includes `compared`.
|
|
756
|
+
- `styleproof-diff` — the certify gate. With no args, it restores cached maps for the current commit and inferred base (`GITHUB_BASE_REF`, `branch.<name>.gh-merge-base`, `gh pr view`, then main/master fallbacks); `styleproof-diff main` / `styleproof-diff master` pins the base; `styleproof-diff <beforeDir> <afterDir>` keeps the manual two-directory form for CI fallback captures. 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; approval policy decides whether to gate). A clean run prints `0 changed surfaces across N captured surface(s)`, and `--json` includes `compared`.
|
|
683
757
|
- `styleproof-report` — render the diff to a Markdown report with before/after crops. With no args, it reports cached maps for the current commit against the inferred base; `styleproof-report main` / `styleproof-report master` pins the base; `styleproof-report <beforeDir> <afterDir> --out <dir>` keeps the manual two-directory form. Add `--include-content` for the opt-in, advisory content section (see above).
|
|
684
758
|
- `styleproof-variants` — crawl a running app for one-step state variants and write `styleproof.variants.generated.json`. Pass `--base-url`, repeat `--route`, and use `--strict` when unresolved skipped/live candidates should fail automation.
|
|
685
759
|
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Capture a single URL's computed-style map — one shot, no spec, no config.
|
|
4
|
+
*
|
|
5
|
+
* styleproof-capture <url> [options]
|
|
6
|
+
*
|
|
7
|
+
* For a page you just want to point at: a deployed URL, a static export, or a
|
|
8
|
+
* standalone HTML mockup. Writes `<key>@<width>.json.gz` (+ `.png`) into --out,
|
|
9
|
+
* the same shape a surface capture writes, so `styleproof-diff <a> <b>` compares
|
|
10
|
+
* it against any other capture. Capture a design mockup once, then diff each
|
|
11
|
+
* build against it to measure how close the implementation is.
|
|
12
|
+
*
|
|
13
|
+
* (Use styleproof-map — the spec-driven flow — for your own app's surfaces, where
|
|
14
|
+
* you also want the coverage guard, the map store, and record/replay.)
|
|
15
|
+
*
|
|
16
|
+
* Exit code 0 = captured, 2 = usage error, 3 = capture failed (e.g. browser
|
|
17
|
+
* missing, page unreachable, cross-origin CSS with no explicit --widths).
|
|
18
|
+
*/
|
|
19
|
+
import { chromium } from '@playwright/test';
|
|
20
|
+
import { isHelpArg, showHelpAndExit } from '../dist/cli-errors.js';
|
|
21
|
+
import { UsageError, parseCaptureUrlArgs, runCaptureUrl, loadSetupSteps } from '../dist/capture-url.js';
|
|
22
|
+
import { crawlAndCapture } from '../dist/crawl-surfaces.js';
|
|
23
|
+
|
|
24
|
+
const COMMAND = 'styleproof-capture';
|
|
25
|
+
|
|
26
|
+
const HELP = `${COMMAND} — capture a page's computed-style map(s) (no spec, no config)
|
|
27
|
+
|
|
28
|
+
usage: ${COMMAND} <url> [options]
|
|
29
|
+
|
|
30
|
+
one state (default): capture the page as it loads
|
|
31
|
+
--key <name> capture file prefix, <key>@<width>.json.gz (default: page)
|
|
32
|
+
--wait <selector> wait for this selector to be visible before capturing
|
|
33
|
+
--widths <csv> viewport widths, e.g. 1440,1024,768. Omit to detect the
|
|
34
|
+
page's own @media breakpoints (fails on cross-origin CSS —
|
|
35
|
+
pass widths for those). The crawl auto-detects too.
|
|
36
|
+
|
|
37
|
+
whole surface: --crawl
|
|
38
|
+
--crawl EXHAUSTIVE: drive every non-destructive control, recurse into
|
|
39
|
+
what opens, and capture every discovered surface under a derived
|
|
40
|
+
key — runs to natural termination, no budget. For a design that's
|
|
41
|
+
mostly modals/drawers/popovers. Destructive-looking controls
|
|
42
|
+
(delete/deploy/pay/revoke…) are never clicked.
|
|
43
|
+
--require-full-coverage
|
|
44
|
+
exit 4 unless every class the page's stylesheets define was
|
|
45
|
+
rendered in a captured surface — the machine check that
|
|
46
|
+
NOTHING in the design was missed (coverage is always printed)
|
|
47
|
+
--setup <file> JSON steps (goto/fill/click/waitFor) run after EVERY fresh
|
|
48
|
+
navigation — how input-gated states (login, unlock) become
|
|
49
|
+
crawlable. \${ENV_VAR} in value/url is read from the
|
|
50
|
+
environment, so secrets never live in the file or the maps.
|
|
51
|
+
--no-data-states skip the automatic loading/error captures of the entry page
|
|
52
|
+
(on by default: data requests stalled → loading skeleton;
|
|
53
|
+
fulfilled with 500 → error render)
|
|
54
|
+
--max-depth <n> throttle recursion depth (default: unbounded)
|
|
55
|
+
--max-actions <n> throttle controls tried per state (default: unbounded)
|
|
56
|
+
--max-states <n> throttle total surfaces (default: unbounded)
|
|
57
|
+
--no-reset-storage don't clear localStorage between steps (default: clear)
|
|
58
|
+
|
|
59
|
+
common:
|
|
60
|
+
--out <dir> output directory (default: styleproof-capture)
|
|
61
|
+
--ignore <sel> skip a nondeterministic region (repeatable)
|
|
62
|
+
--height <px> viewport height (default: 800)
|
|
63
|
+
--no-screenshots write lean .json.gz maps only (screenshots on by default)
|
|
64
|
+
-h, --help show this help
|
|
65
|
+
|
|
66
|
+
Then diff against another capture — zero diff = pixel-identical:
|
|
67
|
+
${COMMAND} https://example.com --crawl --out design
|
|
68
|
+
styleproof-diff design .styleproof/maps/current
|
|
69
|
+
|
|
70
|
+
exit: 0 captured, 2 usage error, 3 capture failed, 4 coverage gap (--require-full-coverage).
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
const argv = process.argv.slice(2);
|
|
74
|
+
if (isHelpArg(argv[0])) showHelpAndExit(HELP);
|
|
75
|
+
|
|
76
|
+
let opts;
|
|
77
|
+
let setupSteps;
|
|
78
|
+
try {
|
|
79
|
+
opts = parseCaptureUrlArgs(argv);
|
|
80
|
+
setupSteps = opts.setupFile ? loadSetupSteps(opts.setupFile) : undefined;
|
|
81
|
+
opts.setup = setupSteps; // one-shot capture honours setup steps too
|
|
82
|
+
} catch (e) {
|
|
83
|
+
if (e instanceof UsageError) {
|
|
84
|
+
console.error(`${COMMAND}: ${e.message}\nNext: run ${COMMAND} --help to see supported options.`);
|
|
85
|
+
process.exit(2);
|
|
86
|
+
}
|
|
87
|
+
throw e;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function runCrawl() {
|
|
91
|
+
const browser = await chromium.launch();
|
|
92
|
+
try {
|
|
93
|
+
const page = await browser.newPage();
|
|
94
|
+
const crawlOpts = {
|
|
95
|
+
url: opts.url,
|
|
96
|
+
out: opts.out,
|
|
97
|
+
widths: opts.widths, // empty = auto-detect the page's real breakpoints
|
|
98
|
+
ignore: opts.ignore,
|
|
99
|
+
height: opts.height,
|
|
100
|
+
screenshots: opts.screenshots,
|
|
101
|
+
waitSelector: opts.waitSelector,
|
|
102
|
+
maxDepth: opts.maxDepth,
|
|
103
|
+
maxActionsPerState: opts.maxActionsPerState,
|
|
104
|
+
maxStates: opts.maxStates,
|
|
105
|
+
resetStorage: opts.resetStorage,
|
|
106
|
+
setup: setupSteps,
|
|
107
|
+
dataStates: opts.dataStates,
|
|
108
|
+
// Stream each surface as it is captured, so progress is visible live and an
|
|
109
|
+
// interrupted run still shows exactly what it mapped.
|
|
110
|
+
onSurface: (s, ok) =>
|
|
111
|
+
console.log(` ${'·'.repeat(s.depth)}${s.key} (${s.elements} elements)${ok ? '' : ' — CAPTURE FAILED'}`),
|
|
112
|
+
};
|
|
113
|
+
const report = await crawlAndCapture(page, crawlOpts);
|
|
114
|
+
console.log(
|
|
115
|
+
`✓ ${report.captured}/${report.surfaces.length} surface(s) × ${crawlOpts.widths.length} width(s) → ${opts.out} ` +
|
|
116
|
+
`(${report.actionsTried} actions tried, ${report.skipped} skipped${report.failed.length ? `, ${report.failed.length} capture-failed` : ''})`,
|
|
117
|
+
);
|
|
118
|
+
const cov = report.coverage;
|
|
119
|
+
if (cov.missing.length === 0) {
|
|
120
|
+
console.log(`✓ coverage: all ${cov.defined} stylesheet classes rendered in at least one captured surface`);
|
|
121
|
+
} else {
|
|
122
|
+
console.log(
|
|
123
|
+
`⚠ coverage: ${cov.rendered}/${cov.defined} stylesheet classes rendered — ${cov.missing.length} never seen ` +
|
|
124
|
+
`(dead CSS, or a state the crawl could not reach):\n ${cov.missing.join(' ')}`,
|
|
125
|
+
);
|
|
126
|
+
if (opts.requireFullCoverage) process.exit(4);
|
|
127
|
+
}
|
|
128
|
+
} finally {
|
|
129
|
+
await browser.close();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
if (opts.crawl) {
|
|
135
|
+
await runCrawl();
|
|
136
|
+
process.exit(0);
|
|
137
|
+
}
|
|
138
|
+
const results = await runCaptureUrl(opts, () => chromium.launch());
|
|
139
|
+
for (const r of results) console.log(`captured ${r.map}${r.screenshot ? ` (+ ${r.screenshot})` : ''}`);
|
|
140
|
+
console.log(`✓ ${results.length} capture(s) → ${opts.out}`);
|
|
141
|
+
process.exit(0);
|
|
142
|
+
} catch (e) {
|
|
143
|
+
console.error(`${COMMAND}: capture failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
144
|
+
console.error(
|
|
145
|
+
'Next: check the URL is reachable and run `npx playwright install chromium` if the browser is missing.',
|
|
146
|
+
);
|
|
147
|
+
process.exit(3);
|
|
148
|
+
}
|
package/bin/styleproof-diff.mjs
CHANGED
|
@@ -181,7 +181,7 @@ console.log(
|
|
|
181
181
|
total === 0
|
|
182
182
|
? newSurfaces === 0
|
|
183
183
|
? `\n✓ 0 changed surfaces across ${compared} captured surface(s): every computed style, pseudo-element, and hover/focus/active state matches`
|
|
184
|
-
: `\nℹ ${newSurfaces} new surface(s) captured with no baseline to compare —
|
|
184
|
+
: `\nℹ ${newSurfaces} new surface(s) captured with no baseline to compare — review before baselining`
|
|
185
185
|
: `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}`,
|
|
186
186
|
);
|
|
187
187
|
// 0 = identical, 1 = reviewable differences, 3 = only new surfaces (no baseline,
|
|
@@ -36,8 +36,8 @@ options:
|
|
|
36
36
|
--remote <name> git remote for the map store (default: ${DEFAULT_REMOTE})
|
|
37
37
|
--out <dir> output directory (default: styleproof-report)
|
|
38
38
|
--image-base-url <url> prefix for image URLs in report.md (default: relative)
|
|
39
|
-
--pad <px> padding around changed rects when cropping (default:
|
|
40
|
-
--max-crops <n> max crop regions per surface before collapsing (default:
|
|
39
|
+
--pad <px> padding around changed rects when cropping (default: 12)
|
|
40
|
+
--max-crops <n> max crop regions per surface before collapsing (default: 8)
|
|
41
41
|
--fold-details-at <n> row count at which a crop's property tables fold under a
|
|
42
42
|
<details> toggle (default: 0 = always; 'Infinity' = never)
|
|
43
43
|
--min-width <px> minimum crop width, for context (default: 320)
|
|
@@ -178,7 +178,7 @@ console.log(
|
|
|
178
178
|
result.changedSurfaces === 0
|
|
179
179
|
? result.newSurfaces === 0
|
|
180
180
|
? '✓ no changes — empty report written'
|
|
181
|
-
: `ℹ ${result.newSurfaces} new surface(s) with no baseline — report written for
|
|
181
|
+
: `ℹ ${result.newSurfaces} new surface(s) with no baseline — report written for review`
|
|
182
182
|
: `✗ ${result.changedSurfaces} changed surface(s), ${result.totalFindings} finding(s)${newNote}`,
|
|
183
183
|
);
|
|
184
184
|
console.log(`report: ${result.reportMdPath}`);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Browser, Page } from '@playwright/test';
|
|
2
|
+
import { type SetupStep } from './crawl-surfaces.js';
|
|
3
|
+
/**
|
|
4
|
+
* One-shot capture of a single URL's computed-style map — no spec, no config,
|
|
5
|
+
* no git. `defineStyleMapCapture` is the right tool when the surfaces live in
|
|
6
|
+
* your own app and you want the coverage guard, the map store, and record/replay.
|
|
7
|
+
* This is the tool for a page you just want to point at: a deployed URL, a
|
|
8
|
+
* static export, or a standalone HTML mockup. Capture it into a directory of
|
|
9
|
+
* `<key>@<width>.json.gz` maps (+ `.png`) that {@link diffStyleMapDirs} — i.e.
|
|
10
|
+
* `styleproof-diff <a> <b>` — compares like any other capture.
|
|
11
|
+
*
|
|
12
|
+
* The output is deliberately the same shape a surface capture writes, so a
|
|
13
|
+
* mockup's map and your app's committed map are directly diffable: capture the
|
|
14
|
+
* design once, then diff each build against it to measure how close the
|
|
15
|
+
* implementation is (a diff that shrinks toward zero as it converges).
|
|
16
|
+
*/
|
|
17
|
+
/** Raised for bad CLI usage so the bin can print help and exit 2. */
|
|
18
|
+
export declare class UsageError extends Error {
|
|
19
|
+
}
|
|
20
|
+
export type CaptureUrlOptions = {
|
|
21
|
+
/** Page to capture. */
|
|
22
|
+
url: string;
|
|
23
|
+
/** Capture file name prefix (`<key>@<width>.json.gz`); default `page`. */
|
|
24
|
+
key: string;
|
|
25
|
+
/**
|
|
26
|
+
* Viewport widths to sweep, one per @media band. Empty = auto-detect from the
|
|
27
|
+
* loaded CSSOM (fails loudly on a cross-origin/unreadable stylesheet — pass
|
|
28
|
+
* widths explicitly for a page whose CSS can't be read, e.g. a cross-origin
|
|
29
|
+
* font stylesheet).
|
|
30
|
+
*/
|
|
31
|
+
widths: number[];
|
|
32
|
+
/** Output directory for the maps (+ screenshots). */
|
|
33
|
+
out: string;
|
|
34
|
+
/** Selectors for nondeterministic regions to skip (passed through to capture). */
|
|
35
|
+
ignore: string[];
|
|
36
|
+
/** Wait for this selector to be visible before capturing (reach the intended state). */
|
|
37
|
+
waitSelector?: string;
|
|
38
|
+
/** Viewport height (default 800). */
|
|
39
|
+
height: number;
|
|
40
|
+
/** Also write a full-page `.png` per capture (default true). */
|
|
41
|
+
screenshots: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Crawl the URL's whole interactive surface instead of capturing one state:
|
|
44
|
+
* drive every non-destructive control, recurse into what opens, capture each
|
|
45
|
+
* discovered surface under a derived key. See {@link crawlAndCapture}.
|
|
46
|
+
*/
|
|
47
|
+
crawl: boolean;
|
|
48
|
+
/** crawl: recursion depth into opened surfaces (default 3). */
|
|
49
|
+
maxDepth: number;
|
|
50
|
+
/** crawl: fresh controls driven per state (default: unbounded — try them all). */
|
|
51
|
+
maxActionsPerState: number;
|
|
52
|
+
/** crawl: safety backstop on total surfaces (default: unbounded — exhaustive). */
|
|
53
|
+
maxStates: number;
|
|
54
|
+
/** crawl: clear storage on each reset so replay is deterministic (default true). */
|
|
55
|
+
resetStorage: boolean;
|
|
56
|
+
/** crawl: exit non-zero unless every class the page's stylesheets define was
|
|
57
|
+
* rendered in at least one captured surface (default false — report only). */
|
|
58
|
+
requireFullCoverage: boolean;
|
|
59
|
+
/** crawl: JSON file of deterministic setup steps (login, unlock, seed input)
|
|
60
|
+
* run after every fresh navigation. See {@link loadSetupSteps}. */
|
|
61
|
+
setupFile?: string;
|
|
62
|
+
/** Loaded setup steps (set by the CLI from `setupFile`); applied after every
|
|
63
|
+
* navigation in BOTH modes, so a gated page's single state is capturable too. */
|
|
64
|
+
setup?: SetupStep[];
|
|
65
|
+
/** crawl: also capture automatic `loading`/`error` data states of the entry
|
|
66
|
+
* page (default true). */
|
|
67
|
+
dataStates: boolean;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Parse `styleproof-capture` argv into options. Pure and throwing so the CLI
|
|
71
|
+
* flow (help/exit codes) and this parse are testable without a browser.
|
|
72
|
+
*/
|
|
73
|
+
export declare function parseCaptureUrlArgs(argv: string[]): CaptureUrlOptions;
|
|
74
|
+
/** Written artifacts for one width. */
|
|
75
|
+
export type CaptureUrlResult = {
|
|
76
|
+
width: number;
|
|
77
|
+
map: string;
|
|
78
|
+
screenshot?: string;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Capture `opts.url` at each width using an already-open {@link Page}, writing
|
|
82
|
+
* `<out>/<key>@<width>.json.gz` (+ `.png`). Re-navigates per width so
|
|
83
|
+
* width-dependent rendering (media queries, `matchMedia`) is captured fresh, and
|
|
84
|
+
* arms the in-flight request tracker before each navigation so the page's own
|
|
85
|
+
* load fetches count toward the network-aware settle — same contract as a
|
|
86
|
+
* surface capture. Returns the files written.
|
|
87
|
+
*/
|
|
88
|
+
export declare function captureUrlToDir(page: Page, opts: CaptureUrlOptions): Promise<CaptureUrlResult[]>;
|
|
89
|
+
/** Launch Chromium, capture the URL, and close — the whole bin body given parsed options. */
|
|
90
|
+
export declare function runCaptureUrl(opts: CaptureUrlOptions, launch: () => Promise<Browser>): Promise<CaptureUrlResult[]>;
|
|
91
|
+
/**
|
|
92
|
+
* Load and validate a `--setup` steps file, interpolating `${ENV_VAR}` in every
|
|
93
|
+
* `value` and `url` from the environment — so credentials for an input-gated
|
|
94
|
+
* page live in env vars, never in the file, the shell history, or the maps.
|
|
95
|
+
* Throws {@link UsageError} on a malformed file or a missing variable.
|
|
96
|
+
*/
|
|
97
|
+
export declare function loadSetupSteps(file: string, env?: NodeJS.ProcessEnv): SetupStep[];
|