styleproof 3.0.0 → 3.0.2
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 +72 -1
- package/README.md +119 -79
- package/dist/capture.d.ts +25 -0
- package/dist/capture.js +41 -0
- package/dist/index.d.ts +2 -2
- package/dist/report.js +65 -15
- package/dist/runner.d.ts +41 -0
- package/dist/runner.js +93 -20
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,53 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.0.2] - 2026-06-27
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **The composite Action now builds its checked-out source before running local
|
|
15
|
+
bins.** Because `dist/` is intentionally gitignored, the v3 Action ref could
|
|
16
|
+
install runtime dependencies and then fail when `bin/styleproof-report.mjs`
|
|
17
|
+
imported `../dist/report.js`. The Action runtime now installs the checkout's dev
|
|
18
|
+
toolchain with scripts disabled, runs `npm run build`, and executes the checked-out
|
|
19
|
+
entrypoints from that built source.
|
|
20
|
+
|
|
21
|
+
## [3.0.1] - 2026-06-27
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **First-class live states plus generic variants.** `Surface` now accepts
|
|
26
|
+
`liveStates`, each captured as `<surface>-<state>` with optional `setup`, `go`,
|
|
27
|
+
`widths`, `height`, and `ignore` overrides. This lets a spec certify both
|
|
28
|
+
`loading` and `loaded` (or `empty` / `error`) on the base branch and feature
|
|
29
|
+
branch, then compare matching states directly instead of relying on one moving
|
|
30
|
+
live page state. Reports label those captures as live states. Generic `variants`
|
|
31
|
+
remain available for non-live states such as nav-open or modal-open. `defineCrawlCapture`
|
|
32
|
+
accepts both and applies them to every discovered link surface.
|
|
33
|
+
- **Semantic live-state candidates are auto-detected.** Captures now record
|
|
34
|
+
diagnostic metadata for `[aria-live]`, implicit live-region roles such as
|
|
35
|
+
`role=status` / `role=alert`, and `aria-busy=true`. Stable candidates are still
|
|
36
|
+
captured and compared by default; only regions that actually keep changing are
|
|
37
|
+
excluded as volatile.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- **Self-check diagnostics now call out volatile root layout drift.** When a
|
|
42
|
+
capture contains volatile regions and the repeated self-check differs on
|
|
43
|
+
`html`/`body` layout properties, the error explains that ignored/live content can
|
|
44
|
+
still move document flow, includes any auto-detected live-state candidates, and
|
|
45
|
+
points users to deterministic `liveStates`.
|
|
46
|
+
- **Release proof now matches CI before publishing.** The release workflow and
|
|
47
|
+
`prepublishOnly` gate now include format checking plus browser e2e coverage before
|
|
48
|
+
npm publication, so a version cannot publish ahead of the full package proof gate.
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- **The composite Action now executes the checked-out Action version.** Report jobs
|
|
53
|
+
install StyleProof's runtime dependencies under `GITHUB_ACTION_PATH` and invoke the
|
|
54
|
+
local `bin/styleproof-*` entrypoints, so `uses: BenSheridanEdwards/StyleProof@v3`
|
|
55
|
+
cannot drift to a consumer workspace install or npm's latest package.
|
|
56
|
+
|
|
10
57
|
## [3.0.0] - 2026-06-27
|
|
11
58
|
|
|
12
59
|
**Milestone: the committed-map gate is how StyleProof works now.** Capture runs
|
|
@@ -794,7 +841,31 @@ number)`), so each viewport band can capture at its own height. Default remains
|
|
|
794
841
|
- `styleproof-diff` CLI: certifies a refactor (exit 0) or names the exact element,
|
|
795
842
|
property, and state that drifted (exit 1).
|
|
796
843
|
|
|
797
|
-
[Unreleased]: https://github.com/BenSheridanEdwards/StyleProof/compare/
|
|
844
|
+
[Unreleased]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.0.2...HEAD
|
|
845
|
+
[3.0.2]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.0.1...v3.0.2
|
|
846
|
+
[3.0.1]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.0.0...v3.0.1
|
|
847
|
+
[3.0.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v2.5.0...v3.0.0
|
|
848
|
+
[2.5.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v2.4.0...v2.5.0
|
|
849
|
+
[2.4.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v2.3.1...v2.4.0
|
|
850
|
+
[2.3.1]: https://github.com/BenSheridanEdwards/StyleProof/compare/v2.3.0...v2.3.1
|
|
851
|
+
[2.3.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v2.2.0...v2.3.0
|
|
852
|
+
[2.2.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v2.1.0...v2.2.0
|
|
853
|
+
[2.1.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v2.0.0...v2.1.0
|
|
854
|
+
[2.0.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.10.0...v2.0.0
|
|
855
|
+
[1.10.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.9.4...v1.10.0
|
|
856
|
+
[1.9.4]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.9.3...v1.9.4
|
|
857
|
+
[1.9.3]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.9.2...v1.9.3
|
|
858
|
+
[1.9.1]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.9.0...v1.9.1
|
|
859
|
+
[1.9.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.8.1...v1.9.0
|
|
860
|
+
[1.8.1]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.8.0...v1.8.1
|
|
861
|
+
[1.8.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.7.2...v1.8.0
|
|
862
|
+
[1.7.2]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.7.1...v1.7.2
|
|
863
|
+
[1.7.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.6.0...v1.7.0
|
|
864
|
+
[1.6.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.5.0...v1.6.0
|
|
865
|
+
[1.5.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.4.0...v1.5.0
|
|
866
|
+
[1.4.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.3.1...v1.4.0
|
|
867
|
+
[1.3.1]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.3.0...v1.3.1
|
|
868
|
+
[1.3.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.2.0...v1.3.0
|
|
798
869
|
[1.2.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.1.0...v1.2.0
|
|
799
870
|
[1.1.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v1.0.0...v1.1.0
|
|
800
871
|
[1.0.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v0.7.0...v1.0.0
|
package/README.md
CHANGED
|
@@ -12,12 +12,12 @@ Pixel-snapshot tools miss most CSS regressions: they can't force `:hover` / `:fo
|
|
|
12
12
|
|
|
13
13
|
## What the gate does
|
|
14
14
|
|
|
15
|
-
On every PR, StyleProof
|
|
15
|
+
On every PR, StyleProof diffs the PR head's computed-style map against the base branch's, and posts a Markdown comment:
|
|
16
16
|
|
|
17
17
|
- A **lean summary comment** linking to a committed side-by-side report — the report is the complete source of truth (**one section per distinct change**, with a before/after cropped screenshot cropped from the same rectangle so the two sides line up exactly, **plain-English bullets that tell you what to look for** — `columns: 2 → 3`, `recoloured cyan → amber` — and the exact property changes). The comment never duplicates the report, so the two can't drift, and it renders identically on public and private repos.
|
|
18
18
|
- A single **Approve all changes** checkbox in the comment, driving a `StyleProof` commit status: red until one tick signs off every change, green when there are none. The reviewer who ticks it is recorded inline (_approved by @them_), sourced from the commit status so it survives a report re-run.
|
|
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
|
+
- The diff is always **head-vs-base**, so the report is _exactly what this PR changes_ — whether the maps are captured fresh in CI or (by default) committed pre-push and diffed browser-free in CI. See [Quickstart](#quickstart).
|
|
21
21
|
|
|
22
22
|
## Don't let a new page ship uncaptured
|
|
23
23
|
|
|
@@ -79,6 +79,42 @@ defineCrawlCapture({
|
|
|
79
79
|
|
|
80
80
|
Each discovered link becomes a surface keyed by its URL (`/?tab=overview` → `overview`; pass `key` for a different scheme). The app only has to render its nav as real `<a href>` links — a button-only nav (`<button onClick>`) exposes nothing to crawl. Replay, self-check and clock-freeze behave exactly as for explicit surfaces; one Playwright test runs the whole sweep (the link set isn't known until the page renders).
|
|
81
81
|
|
|
82
|
+
**Live UI states: capture each state, not an average.** StyleProof automatically
|
|
83
|
+
detects semantic live-state candidates (`aria-live`, `role=status`, `role=alert`,
|
|
84
|
+
`aria-busy=true`) and keeps stable ones in the normal diff. If a stream, poll, or
|
|
85
|
+
live region represents product states you want certified (`loading`, `loaded`,
|
|
86
|
+
`empty`, `error`), list only those pinned states with `liveStates`. StyleProof
|
|
87
|
+
writes separate captures such as `dashboard-loading@1440` and
|
|
88
|
+
`dashboard-loaded@1440`, so the base branch's loading state compares to the
|
|
89
|
+
feature branch's loading state, and loaded compares to loaded.
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
defineStyleMapCapture({
|
|
93
|
+
dir: process.env.STYLEMAP_DIR,
|
|
94
|
+
surfaces: [
|
|
95
|
+
{
|
|
96
|
+
key: 'dashboard',
|
|
97
|
+
go: (page) => page.goto('/dashboard'),
|
|
98
|
+
widths: [1440, 768],
|
|
99
|
+
liveStates: [
|
|
100
|
+
{
|
|
101
|
+
key: 'loading',
|
|
102
|
+
setup: (page) =>
|
|
103
|
+
page.route('**/api/widgets', (route) => route.fulfill({ json: { status: 'loading', widgets: [] } })),
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
key: 'loaded',
|
|
107
|
+
setup: (page) =>
|
|
108
|
+
page.route('**/api/widgets', (route) =>
|
|
109
|
+
route.fulfill({ json: { status: 'loaded', widgets: [{ label: 'Revenue' }] } }),
|
|
110
|
+
),
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
],
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
82
118
|
## What a report looks like
|
|
83
119
|
|
|
84
120
|
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.
|
|
@@ -102,6 +138,10 @@ _landing @ 1280_
|
|
|
102
138
|
|
|
103
139
|
StyleProof reads the browser's **computed styles** — the values it actually resolves — never your source CSS. Tailwind, CSS Modules, styled-components, Sass, vanilla CSS, inline styles: all produce the same computed output, and that's what it diffs. Elements are keyed by **DOM structure, not class name**, so a refactor that rewrites every `class` still lines up element-for-element.
|
|
104
140
|
|
|
141
|
+
## Breakpoints, detected automatically
|
|
142
|
+
|
|
143
|
+
Omit `widths` on a surface and StyleProof reads your app's real `@media` breakpoints from the **loaded CSSOM** at capture time and sweeps one viewport per band — no config. It's framework-agnostic for the same reason the diff is: it reads the rules the browser actually parsed, not your source, so Tailwind / CSS Modules / Sass / vanilla all resolve to the same `@media` boundaries. And it's authoritative **or it fails** — an unreadable cross-origin stylesheet throws rather than silently miss a band; it never guesses. Pin `widths` explicitly when you want a fixed sweep, or to cover a JS-only (`matchMedia`) breakpoint that has no CSS rule.
|
|
144
|
+
|
|
105
145
|
## Certify a refactor
|
|
106
146
|
|
|
107
147
|
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.
|
|
@@ -117,70 +157,60 @@ Requires **Node ≥ 18** (ESM), **`@playwright/test` ≥ 1.40** (peer dep). Forc
|
|
|
117
157
|
|
|
118
158
|
## Quickstart
|
|
119
159
|
|
|
120
|
-
|
|
160
|
+
After installing (above), one command sets up — and **activates** — the whole gate:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
npx styleproof-init
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
It scaffolds:
|
|
167
|
+
|
|
168
|
+
- a **capture spec** (`e2e/styleproof.spec.ts`) describing your surfaces (a Next.js app gets its routes _and_ the coverage guard wired automatically — see below);
|
|
169
|
+
- a **`playwright.config.ts`** that builds and serves a **production build** (never a flaky dev server) and captures surfaces **in parallel** (`fullyParallel`);
|
|
170
|
+
- a **pre-push hook** (`.githooks/pre-push`, activated for you via `core.hooksPath` — it won't clobber an existing husky setup) that captures the map, commits it as a lean `.json.gz` under `stylemaps/`, and **pushes it with your branch — one `git push`, never two**;
|
|
171
|
+
- a **browser-less CI workflow** that diffs your committed map against the base branch.
|
|
172
|
+
|
|
173
|
+
Describe your surfaces — **omit `widths`** and StyleProof sweeps your real `@media` breakpoints automatically:
|
|
121
174
|
|
|
122
175
|
```ts
|
|
123
176
|
import { defineStyleMapCapture } from 'styleproof';
|
|
124
177
|
|
|
125
178
|
defineStyleMapCapture({
|
|
126
|
-
dir: process.env.STYLEMAP_DIR, // inert until set, so it lives safely beside other tests
|
|
127
179
|
surfaces: [
|
|
128
180
|
{
|
|
129
181
|
key: 'landing',
|
|
130
|
-
go: (page) => page.goto('/'), //
|
|
131
|
-
widths: [1280, 768, 390]
|
|
182
|
+
go: (page) => page.goto('/'), // StyleProof settles the page (in-flight data, fonts, animations) before it reads
|
|
183
|
+
// no `widths` → auto-detected from your @media bands; set `widths: [1280, 768, 390]` to pin them
|
|
132
184
|
},
|
|
133
185
|
],
|
|
186
|
+
dir: process.env.STYLEMAP_DIR,
|
|
134
187
|
});
|
|
135
188
|
```
|
|
136
189
|
|
|
137
|
-
**
|
|
190
|
+
**That's the whole loop.** Capture happens **pre-push on your machine** (where the app already builds and serves); the lean map is committed and pushed; and **CI is a browser-less diff of two precomputed maps** — no build, no browser, just a fast comparison (~5400× cheaper on the compare step than re-capturing both sides in CI). `main` always carries a base map, so every PR is _your code vs the committed base_. Require the `StyleProof` status in branch protection and an unverified visual change can't merge.
|
|
191
|
+
|
|
192
|
+
> **Same-environment note.** Computed styles depend on the browser build and installed fonts, so maps are only comparable when captured in the same environment (your machine, or a pinned container). The self-check fails loudly on a non-deterministic capture, so drift never passes silently.
|
|
193
|
+
|
|
194
|
+
**Want the side-by-side report + one-click approval** (not just a pass/fail diff)? Use the Action, pointing it at the base ref:
|
|
138
195
|
|
|
139
196
|
```yaml
|
|
140
197
|
# .github/workflows/styleproof.yml
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
contents: write # push the report branch
|
|
149
|
-
pull-requests: write # post/update the comment
|
|
150
|
-
statuses: write # set the StyleProof status
|
|
151
|
-
steps:
|
|
152
|
-
- uses: actions/checkout@v4
|
|
153
|
-
with:
|
|
154
|
-
fetch-depth: 0 # need the base branch too
|
|
155
|
-
|
|
156
|
-
# capture the base branch
|
|
157
|
-
- run: git checkout ${{ github.event.pull_request.base.sha }}
|
|
158
|
-
- run: npm ci && npm run build && (npm run serve &) # your framework's build + serve
|
|
159
|
-
- run: npx wait-on http://localhost:3000
|
|
160
|
-
- run: STYLEMAP_DIR=base npx playwright test e2e/styleproof.spec.ts
|
|
161
|
-
|
|
162
|
-
# capture the PR head — replay the base's recorded data so the diff is
|
|
163
|
-
# code, not live-data drift (see "Deterministic by default" below)
|
|
164
|
-
- run: git checkout ${{ github.event.pull_request.head.sha }}
|
|
165
|
-
- run: npm ci && npm run build && (npm run serve &)
|
|
166
|
-
- run: npx wait-on http://localhost:3000
|
|
167
|
-
- run: STYLEMAP_DIR=head STYLEPROOF_REPLAY_FROM=__stylemaps__/base npx playwright test e2e/styleproof.spec.ts
|
|
168
|
-
|
|
169
|
-
# report + gate
|
|
170
|
-
- uses: BenSheridanEdwards/StyleProof@v2
|
|
171
|
-
with:
|
|
172
|
-
baseline-dir: __stylemaps__/base # captures land under baseDir (default __stylemaps__)
|
|
173
|
-
fresh-dir: __stylemaps__/head
|
|
174
|
-
require-approval: true # review-gate mode (omit / use fail-on-diff: true to certify)
|
|
198
|
+
- uses: actions/checkout@v4
|
|
199
|
+
with: { fetch-depth: 0 } # need the base ref's committed map
|
|
200
|
+
- uses: BenSheridanEdwards/StyleProof@v3
|
|
201
|
+
with:
|
|
202
|
+
base-ref: origin/${{ github.event.pull_request.base.ref }} # read the base map from git
|
|
203
|
+
fresh-dir: stylemaps/current # your committed maps
|
|
204
|
+
require-approval: true # review-gate mode (omit / use fail-on-diff: true to certify)
|
|
175
205
|
```
|
|
176
206
|
|
|
177
|
-
|
|
207
|
+
Then copy [`example/styleproof-approve.yml`](https://github.com/BenSheridanEdwards/StyleProof/blob/main/example/styleproof-approve.yml) to `.github/workflows/` **on your default branch** (GitHub only runs `issue_comment` workflows from there, so the approval checkbox is inert until it's merged).
|
|
178
208
|
|
|
179
|
-
**
|
|
209
|
+
**Prefer to capture in CI instead of committing maps?** For a repo with many outside contributors on different machines, StyleProof can capture **both** base and head in CI and diff them there — no committed maps. See **[Forks and Dependabot](#forks-and-dependabot)** for that flow (it's also the fork-safe split). The committed-map flow above is the default because it's far faster and simpler for a single team.
|
|
180
210
|
|
|
181
211
|
## Forks and Dependabot
|
|
182
212
|
|
|
183
|
-
|
|
213
|
+
If you **capture in CI** rather than committing maps (the alternative noted at the end of the Quickstart — a better fit when many outside contributors push from different machines), the simplest setup runs the whole gate in one `pull_request` job that captures base + head and diffs them. That job needs a **write** token to push the report branch, post the comment, and set the `StyleProof` status. That's fine for same-repo PRs, but **fork and Dependabot PRs run with a read-only `GITHUB_TOKEN`** (GitHub's security default for untrusted PRs). So the job can't post the status — and a required `StyleProof` check then sits `pending` forever, blocking the PR even though a dependency or fork change usually touches no UI at all.
|
|
184
214
|
|
|
185
215
|
Fix it by splitting capture from reporting, the way the approve workflow is already split out:
|
|
186
216
|
|
|
@@ -191,18 +221,20 @@ That last point is why this works where `pull_request_target` does not: StylePro
|
|
|
191
221
|
|
|
192
222
|
**Where the PR identity comes from.** The report stage comments on the PR and sets the `StyleProof` status against a specific PR number and head commit, so those values have to be trustworthy. It takes them from the trusted `workflow_run` event — `head_sha`, then the event's `pull_requests`, with a commit→PR lookup against that **same trusted head SHA** for fork PRs (whose association the event doesn't carry directly) — and **never** from the downloaded artifact. The artifact is produced by the untrusted capture job, so treating anything in it as identity would let a malicious PR point the privileged comment and status at a victim PR or an arbitrary commit (a confused-deputy attack). The artifact therefore carries only the style-map captures, consumed purely as diff input.
|
|
193
223
|
|
|
194
|
-
Copy both `capture` and `report` files to `.github/workflows/` (the `report` one must be on your default branch, like `styleproof-approve.yml`), then require the `StyleProof` status
|
|
224
|
+
Copy both `capture` and `report` files to `.github/workflows/` (the `report` one must be on your default branch, like `styleproof-approve.yml`), then require the `StyleProof` status in branch protection. A single combined `pull_request` job that captures base + head and diffs them is fine for repos that never see fork or bot PRs; this split is only needed for untrusted PRs.
|
|
195
225
|
|
|
196
226
|
**Deterministic by default — no fixtures required.** A style diff only means something if both sides saw the same inputs; otherwise live-data drift (a backend blip, a `5m ago` timestamp, a status chip that flips) reads as a style change on a PR that touched no CSS. StyleProof handles this for you:
|
|
197
227
|
|
|
198
|
-
- **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>` (
|
|
228
|
+
- **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>` (set on the head capture); tune the data boundary with `STYLEPROOF_REPLAY_URL` / `replayUrl` if your API isn't under `/api`.
|
|
199
229
|
- **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.
|
|
200
230
|
- **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.
|
|
201
231
|
- **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.
|
|
202
232
|
|
|
203
233
|
> 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.
|
|
204
234
|
|
|
205
|
-
**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.
|
|
235
|
+
**Live pages just work when the intended state is deterministic.** 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.
|
|
236
|
+
|
|
237
|
+
Anything still moving on its own after that is detected as a volatile region and excluded from direct element comparison, so a stream or ticker never reads as a change just because its value changed. That is not the same as certifying every state of the live UI: an ignored or volatile subtree can still change `html`/`body` layout if its height changes. When those states matter, make them deterministic `liveStates` (`loading`, `loaded`, `empty`, `error`) and capture each on both branches. Self-check and reports automatically mention detected live-state candidates when volatile layout drift appears. `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 } }`.
|
|
206
238
|
|
|
207
239
|
**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:
|
|
208
240
|
|
|
@@ -212,7 +244,8 @@ Copy both `capture` and `report` files to `.github/workflows/` (the `report` one
|
|
|
212
244
|
| Animations, transitions, focus ring, caret | frozen / blurred before the map is read |
|
|
213
245
|
| Clock-derived styling (`stale > 1h → red`) | `Date.now()` / `new Date()` frozen to a fixed instant |
|
|
214
246
|
| Framework & non-visual noise (`<script>`, route announcers) | skipped by default |
|
|
215
|
-
|
|
|
247
|
+
| Semantic live-state candidates (`aria-live`, `role=status`) | auto-detected and kept in the diff when stable |
|
|
248
|
+
| Live / volatile regions (tickers, third-party embeds) | auto-detected as still-moving and excluded from direct element comparison |
|
|
216
249
|
| Non-deterministic capture (replay gap, unseeded randomness) | self-check flags it _while recording_, with a named error |
|
|
217
250
|
|
|
218
251
|
| You set this — only because it's app-specific | Why it exists |
|
|
@@ -220,6 +253,8 @@ Copy both `capture` and `report` files to `.github/workflows/` (the `report` one
|
|
|
220
253
|
| `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. |
|
|
221
254
|
| `replayUrl` / `STYLEPROOF_REPLAY_URL` | Your data endpoints aren't under `**/api/**`. |
|
|
222
255
|
| `ignore: ['.selector']` | You want a region gone **explicitly** — auto-exclude already handles most live regions, but a known-noisy element reads clearer named. |
|
|
256
|
+
| `liveStates: [{ key, setup, go }]` | A live feature has real states to certify. Capture each state on base and head (`surface-loading`, `surface-loaded`) instead of relying on a single moving page state. |
|
|
257
|
+
| `variants: [{ key, setup, go }]` | Non-live deterministic variants, such as nav-open or modal-open states. |
|
|
223
258
|
| `clockTime` | Your styling keys off a **specific** date, not just "now". |
|
|
224
259
|
| `stabilize: { quietFor, timeout }` | An unusually slow surface needs a longer quiet window before the map is read. |
|
|
225
260
|
|
|
@@ -262,15 +297,16 @@ When a PR **adds** an element, StyleProof now reports its **full resting compute
|
|
|
262
297
|
|
|
263
298
|
## Reference
|
|
264
299
|
|
|
265
|
-
**Action `BenSheridanEdwards/StyleProof@
|
|
300
|
+
**Action `BenSheridanEdwards/StyleProof@v3`** — key inputs:
|
|
266
301
|
|
|
267
|
-
| Input | Default | Purpose
|
|
268
|
-
| ------------------ | ------------ |
|
|
269
|
-
| `
|
|
270
|
-
| `
|
|
271
|
-
| `
|
|
272
|
-
| `
|
|
273
|
-
| `
|
|
302
|
+
| Input | Default | Purpose |
|
|
303
|
+
| ------------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
|
|
304
|
+
| `fresh-dir` | _required_ | PR-head (or committed) captures to compare. |
|
|
305
|
+
| `base-ref` | _none_ | Read the baseline from a git ref (e.g. the PR base branch) instead of `baseline-dir` — the committed-map flow. Needs `fetch-depth: 0`. |
|
|
306
|
+
| `baseline-dir` | _required\*_ | Base-branch captures dir. _\*Not required when `base-ref` is set._ |
|
|
307
|
+
| `require-approval` | `false` | Review-gate mode: set the `StyleProof` status instead of failing. |
|
|
308
|
+
| `fail-on-diff` | `true` | Certify mode: fail on any diff. Ignored when `require-approval` is true. |
|
|
309
|
+
| `status-context` | `StyleProof` | Commit-status name. Must match the approve workflow and branch protection. |
|
|
274
310
|
|
|
275
311
|
Outputs: `changed` (`"true"` when anything changed), `report-url`. Other inputs (`report-branch`, `github-token`) have sensible defaults — see [`action.yml`](https://github.com/BenSheridanEdwards/StyleProof/blob/main/action.yml).
|
|
276
312
|
|
|
@@ -292,38 +328,42 @@ It's **asynchronous by design**: approval is a checkbox tick handled by a separa
|
|
|
292
328
|
|
|
293
329
|
**Capture spec `defineStyleMapCapture({ surfaces, … })`** — determinism is on by default; you rarely set more than `surfaces` and `dir`:
|
|
294
330
|
|
|
295
|
-
| Option | Default | Purpose
|
|
296
|
-
| ------------- | --------------------------- |
|
|
297
|
-
| `surfaces` | _required_ | Page states to certify — each `{ key, go, widths?, ignore?, height? }`. `go(page)` drives to a settled state. Omit `widths` to auto-detect the app's `@media` breakpoints and sweep one width per band. |
|
|
298
|
-
| `
|
|
299
|
-
| `
|
|
300
|
-
| `
|
|
301
|
-
| `
|
|
302
|
-
| `
|
|
303
|
-
| `
|
|
304
|
-
| `
|
|
305
|
-
| `
|
|
306
|
-
| `
|
|
307
|
-
| `
|
|
331
|
+
| Option | Default | Purpose |
|
|
332
|
+
| ------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
333
|
+
| `surfaces` | _required_ | Page states to certify — each `{ key, go, widths?, ignore?, height?, liveStates?, variants? }`. `go(page)` drives to a settled state. Omit `widths` to auto-detect the app's `@media` breakpoints and sweep one width per band. |
|
|
334
|
+
| `liveStates` | _none_ | Optional pinned live product states. Each `{ key, setup?, go?, widths?, height?, ignore? }` becomes `<surface>-<state>` and is labeled as a live state in reports. |
|
|
335
|
+
| `variants` | _none_ | Optional non-live deterministic states under a surface. Each `{ key, setup?, go?, widths?, height?, ignore? }` becomes `<surface>-<variant>` so base/head compare matching states. |
|
|
336
|
+
| `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. |
|
|
337
|
+
| `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. |
|
|
338
|
+
| `dir` | `STYLEMAP_DIR` | Output label (`base`/`head`); the spec is **inert until set**, so it sits safely beside your other specs. |
|
|
339
|
+
| `replayFrom` | `STYLEPROOF_REPLAY_FROM` | Baseline dir whose recorded responses to replay. Unset → this run **records** its HAR for the comparison to use. |
|
|
340
|
+
| `replayUrl` | `**/api/**` (`…REPLAY_URL`) | URL glob for the data boundary to record/replay; everything else (JS/CSS/fonts) loads live so the code runs. |
|
|
341
|
+
| `freezeClock` | `true` | Pin `Date.now()`/`new Date()` so time-derived styling can't drift; timers keep running so settling still works. |
|
|
342
|
+
| `clockTime` | `2025-01-01T00:00:00Z` | The frozen instant. |
|
|
343
|
+
| `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. |
|
|
344
|
+
| `screenshots` | `true` | Save full-page screenshots for the report's before/after crops. |
|
|
345
|
+
| `baseDir` | `__stylemaps__` | Output root directory. |
|
|
308
346
|
|
|
309
347
|
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.
|
|
310
348
|
|
|
311
349
|
**Capture env vars** (wire CI without editing the spec):
|
|
312
350
|
|
|
313
|
-
| Env | Purpose
|
|
314
|
-
| ------------------------ |
|
|
315
|
-
| `STYLEMAP_DIR` | Output label; the capture is skipped entirely when unset.
|
|
316
|
-
| `
|
|
317
|
-
| `
|
|
318
|
-
| `
|
|
351
|
+
| Env | Purpose |
|
|
352
|
+
| ------------------------ | --------------------------------------------------------------------------------------------- |
|
|
353
|
+
| `STYLEMAP_DIR` | Output label; the capture is skipped entirely when unset. |
|
|
354
|
+
| `STYLEPROOF_BASEDIR` | Output root dir (default `__stylemaps__`). The committed-map hook sets this to a tracked dir. |
|
|
355
|
+
| `STYLEPROOF_SCREENSHOTS` | `0` to skip full-page screenshots — lean, commit-friendly `.json.gz`-only maps. |
|
|
356
|
+
| `STYLEPROOF_REPLAY_FROM` | Baseline dir to replay recorded data from — set this on the **head** capture. |
|
|
357
|
+
| `STYLEPROOF_REPLAY_URL` | Override the `**/api/**` data-boundary glob. |
|
|
358
|
+
| `STYLEPROOF_SELFCHECK` | `1` to capture each surface twice and fail if the two differ. |
|
|
319
359
|
|
|
320
360
|
**CLIs** (every flag accepts `--flag value` and `--flag=value`; `--help` lists all):
|
|
321
361
|
|
|
322
|
-
- `styleproof-init` — scaffold the
|
|
323
|
-
- `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).
|
|
324
|
-
- `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).
|
|
362
|
+
- `styleproof-init` — scaffold **and activate** the whole gate: the capture spec, a `playwright.config.ts` (production-build `webServer`, parallel capture), the pre-push capture-and-push hook, and the browser-less CI workflow. One command.
|
|
363
|
+
- `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). Use **`styleproof-diff --base-ref <gitref> <mapsDir>`** to diff your committed maps against a git ref (the committed-map flow) — no second dir, no recapture.
|
|
364
|
+
- `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); **`--base-ref <gitref> <mapsDir>`** reads the base from git like the diff CLI.
|
|
325
365
|
|
|
326
|
-
A programmatic API
|
|
366
|
+
A programmatic API is also exported — `captureStyleMap`, `diffStyleMaps`, `generateStyleMapReport`, and the breakpoint helpers `detectViewportWidths` / `widthsFromBoundaries`, among others. 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.
|
|
327
367
|
|
|
328
368
|
## License
|
|
329
369
|
|
package/dist/capture.d.ts
CHANGED
|
@@ -55,7 +55,23 @@ export type ElementEntry = {
|
|
|
55
55
|
props?: Record<string, string>;
|
|
56
56
|
};
|
|
57
57
|
};
|
|
58
|
+
export type CaptureMetadata = {
|
|
59
|
+
surfaceKey?: string;
|
|
60
|
+
variantKey?: string;
|
|
61
|
+
variantKind?: 'variant' | 'live-state';
|
|
62
|
+
};
|
|
63
|
+
export type LiveRegionCandidate = {
|
|
64
|
+
path: string;
|
|
65
|
+
tag: string;
|
|
66
|
+
cls: string;
|
|
67
|
+
reason: string;
|
|
68
|
+
role?: string;
|
|
69
|
+
ariaLive?: string;
|
|
70
|
+
ariaBusy?: string;
|
|
71
|
+
};
|
|
58
72
|
export type StyleMap = {
|
|
73
|
+
/** Optional runner-supplied context; ignored by the certification diff. */
|
|
74
|
+
metadata?: CaptureMetadata;
|
|
59
75
|
defaults: Record<string, Props>;
|
|
60
76
|
elements: Record<string, ElementEntry>;
|
|
61
77
|
states: Record<string, Record<string, Record<string, Props>>>;
|
|
@@ -74,6 +90,13 @@ export type StyleMap = {
|
|
|
74
90
|
* stream/ticker never reads as a change. Empty/absent on a fully-settled page.
|
|
75
91
|
*/
|
|
76
92
|
volatile?: string[];
|
|
93
|
+
/**
|
|
94
|
+
* Semantic live-state candidates detected automatically from the DOM, such as
|
|
95
|
+
* `[aria-live]`, `role=status`, `role=alert`, or `aria-busy=true`. These are
|
|
96
|
+
* diagnostics only: stable candidates are still captured and compared; only
|
|
97
|
+
* regions that actually keep changing are auto-excluded via `volatile`.
|
|
98
|
+
*/
|
|
99
|
+
liveCandidates?: LiveRegionCandidate[];
|
|
77
100
|
/**
|
|
78
101
|
* Colour-valued `:root` custom properties (design/theme tokens), normalised to
|
|
79
102
|
* the same `rgb(...)` form the longhands resolve to — `{ "--red-200": "rgb(254,
|
|
@@ -171,6 +194,8 @@ export type CaptureOptions = {
|
|
|
171
194
|
* in flight when you called it (arm one yourself before `goto` if that matters).
|
|
172
195
|
*/
|
|
173
196
|
pendingRequests?: () => number;
|
|
197
|
+
/** Advanced/internal: metadata to persist with the capture for report context. */
|
|
198
|
+
metadata?: CaptureMetadata;
|
|
174
199
|
};
|
|
175
200
|
/** True if `path` is one of `roots` or a structural descendant of one. Shared by
|
|
176
201
|
* the capture (excluding live regions) and the diff (skipping them). */
|
package/dist/capture.js
CHANGED
|
@@ -229,6 +229,41 @@ function capturePage({ ignore, motionOnly, captureText, captureComponent }) {
|
|
|
229
229
|
frame.remove();
|
|
230
230
|
return { defaults, elements, shadowHosts, sameOriginFrames };
|
|
231
231
|
}
|
|
232
|
+
function detectLiveCandidates({ ignore }) {
|
|
233
|
+
const pathOf = window.__spPathOf;
|
|
234
|
+
const skipSel = ignore.length ? ignore.map((s) => `${s}, ${s} *`).join(', ') : '';
|
|
235
|
+
const reasonsFor = (role, ariaLive, ariaBusy) => {
|
|
236
|
+
const reasons = [];
|
|
237
|
+
if (ariaLive && ariaLive !== 'off')
|
|
238
|
+
reasons.push(`aria-live=${ariaLive}`);
|
|
239
|
+
if (['alert', 'log', 'marquee', 'status', 'timer'].includes(role))
|
|
240
|
+
reasons.push(`role=${role}`);
|
|
241
|
+
if (ariaBusy === 'true')
|
|
242
|
+
reasons.push('aria-busy=true');
|
|
243
|
+
return reasons;
|
|
244
|
+
};
|
|
245
|
+
return [document.documentElement, document.body, ...document.querySelectorAll('body *')]
|
|
246
|
+
.filter((el) => !skipSel || !el.matches(skipSel))
|
|
247
|
+
.flatMap((el) => {
|
|
248
|
+
const role = (el.getAttribute('role') ?? '').trim().toLowerCase();
|
|
249
|
+
const ariaLive = (el.getAttribute('aria-live') ?? '').trim().toLowerCase();
|
|
250
|
+
const ariaBusy = (el.getAttribute('aria-busy') ?? '').trim().toLowerCase();
|
|
251
|
+
const reasons = reasonsFor(role, ariaLive, ariaBusy);
|
|
252
|
+
return reasons.length
|
|
253
|
+
? [
|
|
254
|
+
{
|
|
255
|
+
path: pathOf(el),
|
|
256
|
+
tag: el.tagName.toLowerCase(),
|
|
257
|
+
cls: el.getAttribute('class') || '',
|
|
258
|
+
reason: reasons.join(', '),
|
|
259
|
+
...(role ? { role } : {}),
|
|
260
|
+
...(ariaLive ? { ariaLive } : {}),
|
|
261
|
+
...(ariaBusy ? { ariaBusy } : {}),
|
|
262
|
+
},
|
|
263
|
+
]
|
|
264
|
+
: [];
|
|
265
|
+
});
|
|
266
|
+
}
|
|
232
267
|
/** Full (unpruned) computed styles for an element and its descendants, pseudo-elements included. */
|
|
233
268
|
// Serialized into the browser by page.evaluate; cannot call module helpers.
|
|
234
269
|
function snapSubtree({ selector, index }) {
|
|
@@ -571,6 +606,10 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
571
606
|
// (a live stream/ticker) to exclude — animations are frozen above, so only
|
|
572
607
|
// real content/layout churn lands here.
|
|
573
608
|
const volatile = await detectVolatile(page, ignore, stabilize, captureText, options.pendingRequests);
|
|
609
|
+
// Detect semantic live-state candidates automatically, but don't exclude them
|
|
610
|
+
// merely for being live regions. Stable status/alert/log UI is product UI and
|
|
611
|
+
// should still be captured; this metadata only improves reports and diagnostics.
|
|
612
|
+
const liveCandidates = await page.evaluate(detectLiveCandidates, { ignore: FRAMEWORK_IGNORE });
|
|
574
613
|
// Motion longhands (transition/animation) are read separately so declared
|
|
575
614
|
// motion is verified even though every other value is a frozen end state.
|
|
576
615
|
// Read them on the SETTLED DOM, not before it: capturing pre-settle missed any
|
|
@@ -595,11 +634,13 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
595
634
|
}
|
|
596
635
|
const tokens = await page.evaluate(capturePageTokens);
|
|
597
636
|
return {
|
|
637
|
+
...(options.metadata ? { metadata: options.metadata } : {}),
|
|
598
638
|
defaults: base.defaults,
|
|
599
639
|
elements: base.elements,
|
|
600
640
|
states,
|
|
601
641
|
...(statesSkipped ? { statesSkipped: true } : {}),
|
|
602
642
|
...(volatile.length ? { volatile } : {}),
|
|
643
|
+
...(liveCandidates.length ? { liveCandidates } : {}),
|
|
603
644
|
...(Object.keys(tokens).length ? { tokens } : {}),
|
|
604
645
|
};
|
|
605
646
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
|
|
2
|
-
export type { StyleMap, CaptureOptions, ElementEntry, Rect } from './capture.js';
|
|
2
|
+
export type { StyleMap, CaptureOptions, CaptureMetadata, ElementEntry, LiveRegionCandidate, Rect } from './capture.js';
|
|
3
3
|
export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
|
|
4
|
-
export type { Surface, DefineOptions, CrawlOptions } from './runner.js';
|
|
4
|
+
export type { Surface, SurfaceLiveState, SurfaceVariant, DefineOptions, CrawlOptions } from './runner.js';
|
|
5
5
|
export { coverageGaps } from './coverage.js';
|
|
6
6
|
export type { CoverageGaps } from './coverage.js';
|
|
7
7
|
export { detectViewportWidths, mediaTextWidthBoundaries, widthsFromBoundaries } from './breakpoints.js';
|
package/dist/report.js
CHANGED
|
@@ -354,14 +354,12 @@ export function prettyLabel(p, cls) {
|
|
|
354
354
|
}
|
|
355
355
|
const surfaceBase = (s) => s.replace(/@\d+$/, '');
|
|
356
356
|
const surfaceWidth = (s) => Number(s.match(/@(\d+)$/)?.[1] ?? 0);
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
byBase.set(surfaceBase(s), arr);
|
|
364
|
-
}
|
|
357
|
+
function pushSurfaceWidth(byBase, base, surface) {
|
|
358
|
+
const arr = byBase.get(base) ?? [];
|
|
359
|
+
arr.push(surfaceWidth(surface));
|
|
360
|
+
byBase.set(base, arr);
|
|
361
|
+
}
|
|
362
|
+
function renderSurfaceGroups(byBase) {
|
|
365
363
|
return [...byBase]
|
|
366
364
|
.map(([base, ws]) => {
|
|
367
365
|
const widths = ws.filter((w) => w > 0).sort((a, b) => b - a);
|
|
@@ -369,6 +367,53 @@ function formatSurfaceList(surfaces) {
|
|
|
369
367
|
})
|
|
370
368
|
.join(' · ');
|
|
371
369
|
}
|
|
370
|
+
/** "landing @ 1280, 1080, 390 · landing-nav-open @ 1080" from the surface keys. */
|
|
371
|
+
function formatSurfaceList(surfaces) {
|
|
372
|
+
const byBase = new Map();
|
|
373
|
+
for (const s of surfaces)
|
|
374
|
+
pushSurfaceWidth(byBase, surfaceBase(s), s);
|
|
375
|
+
return renderSurfaceGroups(byBase);
|
|
376
|
+
}
|
|
377
|
+
function surfaceContext(...maps) {
|
|
378
|
+
const metadata = maps.find((m) => m?.metadata)?.metadata;
|
|
379
|
+
if (!metadata?.variantKey)
|
|
380
|
+
return '';
|
|
381
|
+
return metadata.variantKind === 'live-state'
|
|
382
|
+
? `live state \`${metadata.variantKey}\``
|
|
383
|
+
: `variant \`${metadata.variantKey}\``;
|
|
384
|
+
}
|
|
385
|
+
function formatSurfaceWithContext(surface, ...maps) {
|
|
386
|
+
const context = surfaceContext(...maps);
|
|
387
|
+
return context ? `${formatSurfaceList([surface])} · ${context}` : formatSurfaceList([surface]);
|
|
388
|
+
}
|
|
389
|
+
function formatSurfaceListWithContext(surfaces, beforeDir) {
|
|
390
|
+
const byBase = new Map();
|
|
391
|
+
for (const surface of surfaces) {
|
|
392
|
+
const map = loadStyleMap(findCapture(beforeDir, surface));
|
|
393
|
+
const context = surfaceContext(map);
|
|
394
|
+
const base = context ? `${surfaceBase(surface)} · ${context}` : surfaceBase(surface);
|
|
395
|
+
pushSurfaceWidth(byBase, base, surface);
|
|
396
|
+
}
|
|
397
|
+
return renderSurfaceGroups(byBase);
|
|
398
|
+
}
|
|
399
|
+
function liveCandidateLabel(candidate) {
|
|
400
|
+
const label = candidate.cls ? `${candidate.tag}.${candidate.cls.split(/\s+/)[0]}` : candidate.tag;
|
|
401
|
+
return `${label} (${candidate.reason})`;
|
|
402
|
+
}
|
|
403
|
+
function captureFiles(dir) {
|
|
404
|
+
return fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => /\.json(\.gz)?$/.test(f)) : [];
|
|
405
|
+
}
|
|
406
|
+
function collectLiveCandidateLabels(beforeDir, afterDir) {
|
|
407
|
+
const seen = new Set();
|
|
408
|
+
for (const dir of [beforeDir, afterDir]) {
|
|
409
|
+
for (const file of captureFiles(dir)) {
|
|
410
|
+
const map = loadStyleMap(path.join(dir, file));
|
|
411
|
+
for (const candidate of map.liveCandidates ?? [])
|
|
412
|
+
seen.add(liveCandidateLabel(candidate));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return [...seen].sort();
|
|
416
|
+
}
|
|
372
417
|
// Grid-track longhands compute to width-dependent pixels (`282px ×2` at one width,
|
|
373
418
|
// `282px 228px` at another), so the SAME responsive change would otherwise get a
|
|
374
419
|
// different signature per width. Key them by track COUNT — what actually
|
|
@@ -753,6 +798,7 @@ export function generateStyleMapReport(opts) {
|
|
|
753
798
|
const includeNoise = opts.includeLayoutNoise ?? false;
|
|
754
799
|
const includeContent = opts.includeContent ?? false;
|
|
755
800
|
const { surfaces, volatile: volatileCount } = diffStyleMapDirs(beforeDir, afterDir);
|
|
801
|
+
const liveCandidateLabels = volatileCount > 0 ? collectLiveCandidateLabels(beforeDir, afterDir) : [];
|
|
756
802
|
fs.mkdirSync(path.join(outDir, 'crops'), { recursive: true });
|
|
757
803
|
// Focus each surface on styling intent: drop reflow-casualty props, suppress
|
|
758
804
|
// forced-state echoes of base changes, and remove non-value noise (see
|
|
@@ -826,7 +872,10 @@ export function generateStyleMapReport(opts) {
|
|
|
826
872
|
}
|
|
827
873
|
}
|
|
828
874
|
if (volatileCount > 0) {
|
|
829
|
-
|
|
875
|
+
const candidates = liveCandidateLabels.length
|
|
876
|
+
? ` Auto-detected live-state candidate(s): ${liveCandidateLabels.slice(0, 5).join('; ')}.`
|
|
877
|
+
: '';
|
|
878
|
+
md.push('', `_${volatileCount} live region(s) auto-excluded as nondeterministic (a stream, ticker, or late-loading content) — they don't affect the check.${candidates}_`);
|
|
830
879
|
}
|
|
831
880
|
if (contentSection.count > 0 && (changeGroups.length > 0 || missing.length > 0)) {
|
|
832
881
|
md.push('', `📝 _${contentSection.count} advisory content change(s) below — they don't affect the check._`);
|
|
@@ -856,9 +905,6 @@ export function generateStyleMapReport(opts) {
|
|
|
856
905
|
// Read top-to-bottom: one section per crop, in page order.
|
|
857
906
|
const topY = (g) => (visible(g.after) ? g.after.y : visible(g.before) ? g.before.y : Infinity);
|
|
858
907
|
groups.sort((a, b) => topY(a) - topY(b));
|
|
859
|
-
const surfaceList = cg.surfaces.length > 1
|
|
860
|
-
? `_Identical across ${cg.surfaces.length} surfaces: ${formatSurfaceList(cg.surfaces)}_`
|
|
861
|
-
: `_${formatSurfaceList(cg.surfaces)}_`;
|
|
862
908
|
const surfaceJson = {
|
|
863
909
|
surfaces: cg.surfaces,
|
|
864
910
|
representative: sd.surface,
|
|
@@ -870,6 +916,9 @@ export function generateStyleMapReport(opts) {
|
|
|
870
916
|
// sit directly under the screenshot that shows them — never a wall of
|
|
871
917
|
// changes spanning several crops with no way to tell which is which.
|
|
872
918
|
const regionFindings = surfaceFindings.filter((f) => g.paths.some((root) => f.path === root || f.path.startsWith(root + ' > ')));
|
|
919
|
+
const surfaceList = cg.surfaces.length > 1
|
|
920
|
+
? `_Identical across ${cg.surfaces.length} surfaces: ${formatSurfaceListWithContext(cg.surfaces, beforeDir)}_`
|
|
921
|
+
: `_${formatSurfaceWithContext(sd.surface, mapA, mapB)}_`;
|
|
873
922
|
md.push('', `### ${regionHeading(g.paths, regionFindings)}`, '', surfaceList);
|
|
874
923
|
const region = visible(g.after) ? g.after : g.before;
|
|
875
924
|
let images = {};
|
|
@@ -901,7 +950,7 @@ export function generateStyleMapReport(opts) {
|
|
|
901
950
|
// Clean before|after shown by default (the real UI); the annotated twin —
|
|
902
951
|
// boxes marking each change — sits one click away under a toggle. Plain
|
|
903
952
|
// images (no link wrap) so a click opens the full-resolution file.
|
|
904
|
-
md.push('', `})`, '', `<sub>◀ before · after ▶ — ${sd.surface}</sub>`, '', '<details>', '<summary>🔍 Highlight what changed</summary>', '', `})`, '', `<sub>magenta boxes mark each change — ${sd.surface}</sub>`, '', '</details>');
|
|
953
|
+
md.push('', `})`, '', `<sub>◀ before · after ▶ — ${formatSurfaceWithContext(sd.surface, mapA, mapB)}</sub>`, '', '<details>', '<summary>🔍 Highlight what changed</summary>', '', `})`, '', `<sub>magenta boxes mark each change — ${formatSurfaceWithContext(sd.surface, mapA, mapB)}</sub>`, '', '</details>');
|
|
905
954
|
}
|
|
906
955
|
else if (!region) {
|
|
907
956
|
md.push('', '_Changed element is not visible in this state (zero-size box) — see the property list._');
|
|
@@ -923,8 +972,9 @@ export function generateStyleMapReport(opts) {
|
|
|
923
972
|
for (const p of missing) {
|
|
924
973
|
const side = p.sd.missing === 'before' ? 'after' : 'before';
|
|
925
974
|
const srcDir = side === 'after' ? afterDir : beforeDir;
|
|
975
|
+
const map = loadStyleMap(findCapture(srcDir, p.sd.surface));
|
|
926
976
|
const png = readPng(path.join(srcDir, `${p.sd.surface}.png`));
|
|
927
|
-
md.push('', `### \`${p.sd.surface}\` · new surface ${NEW_SURFACE_MARKER}`, '', `_${
|
|
977
|
+
md.push('', `### \`${p.sd.surface}\` · new surface ${NEW_SURFACE_MARKER}`, '', `_${formatSurfaceWithContext(p.sd.surface, map)}_`);
|
|
928
978
|
const surfaceJson = { surface: p.sd.surface, missing: p.sd.missing, isNew: true };
|
|
929
979
|
if (png) {
|
|
930
980
|
cropSeq++;
|
|
@@ -932,7 +982,7 @@ export function generateStyleMapReport(opts) {
|
|
|
932
982
|
const crop = cropPng(png, { x: 0, y: 0, w: png.width, h }, png.width, h).png;
|
|
933
983
|
const stem = `crops/${p.sd.surface.replace(/[^a-z0-9-]/gi, '-')}-${cropSeq}-new`;
|
|
934
984
|
writePng(path.join(outDir, `${stem}.png`), crop);
|
|
935
|
-
md.push('', `})`, '', `<sub>${side} · ${p.sd.surface}${png.height > h ? ' (top of page)' : ''}</sub>`);
|
|
985
|
+
md.push('', `})`, '', `<sub>${side} · ${formatSurfaceWithContext(p.sd.surface, map)}${png.height > h ? ' (top of page)' : ''}</sub>`);
|
|
936
986
|
surfaceJson.image = `${stem}.png`;
|
|
937
987
|
}
|
|
938
988
|
else {
|
package/dist/runner.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type CaptureMetadata, type LiveRegionCandidate } from './capture.js';
|
|
2
|
+
import { type Finding } from './diff.js';
|
|
1
3
|
import { type LinkMatch } from './crawl.js';
|
|
2
4
|
import type { Page } from '@playwright/test';
|
|
3
5
|
/**
|
|
@@ -26,7 +28,37 @@ export type Surface = {
|
|
|
26
28
|
widths?: number[];
|
|
27
29
|
/** Viewport height: a number, or a function of the width (default 800). */
|
|
28
30
|
height?: number | ((width: number) => number);
|
|
31
|
+
/**
|
|
32
|
+
* Optional deterministic states of this same surface. Each variant becomes its
|
|
33
|
+
* own capture (`<surface>-<variant>@<width>`), so base/head compare loading to
|
|
34
|
+
* loading and loaded to loaded instead of treating live UI as one fuzzy state.
|
|
35
|
+
*/
|
|
36
|
+
variants?: SurfaceVariant[];
|
|
37
|
+
/**
|
|
38
|
+
* First-class live product states for this surface. Use this for loading,
|
|
39
|
+
* loaded, empty, error, streaming, etc. StyleProof records them as live-state
|
|
40
|
+
* variants so reports and diagnostics can explain why the capture was split.
|
|
41
|
+
*/
|
|
42
|
+
liveStates?: SurfaceLiveState[];
|
|
43
|
+
};
|
|
44
|
+
export type SurfaceVariant = {
|
|
45
|
+
/** Capture key suffix, joined as `<surface.key>-<variant.key>`. */
|
|
46
|
+
key: string;
|
|
47
|
+
/**
|
|
48
|
+
* Seed the state before the parent surface navigates: route mocks, fixture data,
|
|
49
|
+
* localStorage/sessionStorage, feature flags, etc.
|
|
50
|
+
*/
|
|
51
|
+
setup?: (page: Page) => Promise<void>;
|
|
52
|
+
/** Drive or assert the variant after the parent surface reaches its base state. */
|
|
53
|
+
go?: (page: Page) => Promise<void>;
|
|
54
|
+
/** Extra ignored selectors for this variant, appended to the parent surface's ignore list. */
|
|
55
|
+
ignore?: string[];
|
|
56
|
+
/** Override the parent viewport widths for this variant. */
|
|
57
|
+
widths?: number[];
|
|
58
|
+
/** Override the parent viewport height for this variant. */
|
|
59
|
+
height?: number | ((width: number) => number);
|
|
29
60
|
};
|
|
61
|
+
export type SurfaceLiveState = SurfaceVariant;
|
|
30
62
|
export type DefineOptions = {
|
|
31
63
|
surfaces: Surface[];
|
|
32
64
|
/**
|
|
@@ -112,6 +144,11 @@ export type DefineOptions = {
|
|
|
112
144
|
*/
|
|
113
145
|
captureComponent?: boolean;
|
|
114
146
|
};
|
|
147
|
+
export declare function selfCheckErrorMessage(surfaceKey: string, drift: Finding[], volatile?: string[], liveCandidates?: LiveRegionCandidate[]): string;
|
|
148
|
+
type ExpandedSurface = Omit<Surface, 'variants' | 'liveStates'> & {
|
|
149
|
+
metadata?: CaptureMetadata;
|
|
150
|
+
};
|
|
151
|
+
export declare function expandSurfaceVariants(surface: Surface): ExpandedSurface[];
|
|
115
152
|
/**
|
|
116
153
|
* Let SSE (EventSource) requests bypass HAR record/replay and reach the live
|
|
117
154
|
* server. A long-lived stream can't round-trip through a HAR entry: recording
|
|
@@ -181,6 +218,10 @@ export type CrawlOptions = CaptureConfig & {
|
|
|
181
218
|
height?: number | ((width: number) => number);
|
|
182
219
|
/** Selectors skipped on every surface (live regions, third-party embeds). */
|
|
183
220
|
ignore?: string[];
|
|
221
|
+
/** Deterministic variants captured for every discovered link surface. */
|
|
222
|
+
variants?: SurfaceVariant[];
|
|
223
|
+
/** First-class live product states captured for every discovered link surface. */
|
|
224
|
+
liveStates?: SurfaceLiveState[];
|
|
184
225
|
/** Max ms to wait for the crawl root's links to render before reading them
|
|
185
226
|
* (an SPA hydrates its nav client-side). Default 15000. */
|
|
186
227
|
linkTimeout?: number;
|
package/dist/runner.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
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, trackInflightRequests } from './capture.js';
|
|
4
|
+
import { captureStyleMap, saveStyleMap, trackInflightRequests, } from './capture.js';
|
|
5
5
|
import { diffStyleMaps } from './diff.js';
|
|
6
6
|
import { coverageGaps } from './coverage.js';
|
|
7
7
|
import { detectViewportWidths } from './breakpoints.js';
|
|
@@ -13,6 +13,77 @@ function driftDesc(f) {
|
|
|
13
13
|
const p = f.props[0];
|
|
14
14
|
return p ? `${f.path} ${p.prop}: ${p.before} → ${p.after}` : f.path;
|
|
15
15
|
}
|
|
16
|
+
const ROOT_LAYOUT_PATHS = new Set(['html', 'body']);
|
|
17
|
+
const ROOT_LAYOUT_PROPS = new Set([
|
|
18
|
+
'block-size',
|
|
19
|
+
'height',
|
|
20
|
+
'inline-size',
|
|
21
|
+
'width',
|
|
22
|
+
'min-block-size',
|
|
23
|
+
'min-height',
|
|
24
|
+
'max-block-size',
|
|
25
|
+
'max-height',
|
|
26
|
+
'perspective-origin',
|
|
27
|
+
'transform-origin',
|
|
28
|
+
]);
|
|
29
|
+
function hasRootLayoutDrift(drift) {
|
|
30
|
+
return drift.some((f) => f.kind === 'style' && ROOT_LAYOUT_PATHS.has(f.path) && f.props.some((p) => ROOT_LAYOUT_PROPS.has(p.prop)));
|
|
31
|
+
}
|
|
32
|
+
function liveCandidateDesc(candidate) {
|
|
33
|
+
const label = candidate.cls ? `${candidate.tag}.${candidate.cls.split(/\s+/)[0]}` : candidate.tag;
|
|
34
|
+
return `${label} (${candidate.reason}) at ${candidate.path}`;
|
|
35
|
+
}
|
|
36
|
+
export function selfCheckErrorMessage(surfaceKey, drift, volatile = [], liveCandidates = []) {
|
|
37
|
+
const first = drift[0];
|
|
38
|
+
let message = `styleproof self-check failed: ${surfaceKey} is non-deterministic — ` +
|
|
39
|
+
`${drift.length} computed-style difference(s) between two captures of the same commit. ` +
|
|
40
|
+
`Likely a replay gap (a request not in the baseline HAR) or unseeded randomness.`;
|
|
41
|
+
if (volatile.length && hasRootLayoutDrift(drift)) {
|
|
42
|
+
message +=
|
|
43
|
+
` Volatile regions were detected in this capture; root/body layout drift usually means live content ` +
|
|
44
|
+
`is still changing document flow. Model those live states with \`liveStates\` instead ` +
|
|
45
|
+
`of only ignoring the region.`;
|
|
46
|
+
if (liveCandidates.length) {
|
|
47
|
+
message += ` Auto-detected live-state candidate(s): ${liveCandidates
|
|
48
|
+
.slice(0, 3)
|
|
49
|
+
.map(liveCandidateDesc)
|
|
50
|
+
.join('; ')}.`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return first ? `${message} First: ${driftDesc(first)}` : message;
|
|
54
|
+
}
|
|
55
|
+
function mergeIgnore(...groups) {
|
|
56
|
+
const merged = [...new Set(groups.flatMap((g) => g ?? []))];
|
|
57
|
+
return merged.length ? merged : undefined;
|
|
58
|
+
}
|
|
59
|
+
function expandOne(surface, variant, variantKind) {
|
|
60
|
+
return {
|
|
61
|
+
key: `${surface.key}-${variant.key}`,
|
|
62
|
+
go: async (page) => {
|
|
63
|
+
await variant.setup?.(page);
|
|
64
|
+
await surface.go(page);
|
|
65
|
+
await variant.go?.(page);
|
|
66
|
+
},
|
|
67
|
+
ignore: mergeIgnore(surface.ignore, variant.ignore),
|
|
68
|
+
widths: variant.widths ?? surface.widths,
|
|
69
|
+
height: variant.height ?? surface.height,
|
|
70
|
+
metadata: { surfaceKey: surface.key, variantKey: variant.key, variantKind },
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export function expandSurfaceVariants(surface) {
|
|
74
|
+
const variants = surface.variants ?? [];
|
|
75
|
+
const liveStates = surface.liveStates ?? [];
|
|
76
|
+
if (!variants.length && !liveStates.length) {
|
|
77
|
+
const { variants: _variants, liveStates: _liveStates, ...base } = surface;
|
|
78
|
+
void _variants;
|
|
79
|
+
void _liveStates;
|
|
80
|
+
return [{ ...base, metadata: { surfaceKey: surface.key } }];
|
|
81
|
+
}
|
|
82
|
+
return [
|
|
83
|
+
...variants.map((variant) => expandOne(surface, variant, 'variant')),
|
|
84
|
+
...liveStates.map((state) => expandOne(surface, state, 'live-state')),
|
|
85
|
+
];
|
|
86
|
+
}
|
|
16
87
|
/**
|
|
17
88
|
* Let SSE (EventSource) requests bypass HAR record/replay and reach the live
|
|
18
89
|
* server. A long-lived stream can't round-trip through a HAR entry: recording
|
|
@@ -77,10 +148,8 @@ async function assertDeterministic(page, surface, first, captureText, pending) {
|
|
|
77
148
|
const again = await captureStyleMap(page, { ignore: surface.ignore ?? [], captureText, pendingRequests: pending });
|
|
78
149
|
const drift = diffStyleMaps(first, again);
|
|
79
150
|
if (drift.length) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
`Likely a replay gap (a request not in the baseline HAR) or unseeded randomness. ` +
|
|
83
|
-
`First: ${driftDesc(drift[0])}`);
|
|
151
|
+
const liveCandidates = [...(first.liveCandidates ?? []), ...(again.liveCandidates ?? [])];
|
|
152
|
+
throw new Error(selfCheckErrorMessage(surface.key, drift, [...new Set([...(first.volatile ?? []), ...(again.volatile ?? [])])], liveCandidates));
|
|
84
153
|
}
|
|
85
154
|
}
|
|
86
155
|
/** Drive one surface at one width to a settled state and save its style map (+ screenshot).
|
|
@@ -101,6 +170,7 @@ async function captureSurface(page, surface, width, s) {
|
|
|
101
170
|
captureText: s.captureText,
|
|
102
171
|
captureComponent: s.captureComponent,
|
|
103
172
|
pendingRequests: requests.pending,
|
|
173
|
+
metadata: surface.metadata,
|
|
104
174
|
});
|
|
105
175
|
if (s.selfCheck)
|
|
106
176
|
await assertDeterministic(page, surface, map, s.captureText, requests.pending);
|
|
@@ -180,6 +250,7 @@ function resolveSettings(c) {
|
|
|
180
250
|
export function defineStyleMapCapture(options) {
|
|
181
251
|
const { surfaces, expected, exclude = {}, dir } = options;
|
|
182
252
|
const settings = resolveSettings(options);
|
|
253
|
+
const captureSurfaces = surfaces.flatMap(expandSurfaceVariants);
|
|
183
254
|
// Coverage guard. Runs in the NORMAL test suite (NOT gated on a capture dir), so
|
|
184
255
|
// a route added without a surface fails the app's own tests — long before, and
|
|
185
256
|
// independent of, a capture run. This is the one gap captures can't catch: a
|
|
@@ -199,7 +270,7 @@ export function defineStyleMapCapture(options) {
|
|
|
199
270
|
}
|
|
200
271
|
test.describe('styleproof capture', () => {
|
|
201
272
|
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
202
|
-
for (const surface of
|
|
273
|
+
for (const surface of captureSurfaces) {
|
|
203
274
|
if (surface.widths && surface.widths.length > 0) {
|
|
204
275
|
// Explicit widths: one parallelizable test per surface × width.
|
|
205
276
|
for (const width of surface.widths) {
|
|
@@ -243,7 +314,7 @@ export function defineStyleMapCapture(options) {
|
|
|
243
314
|
* ```
|
|
244
315
|
*/
|
|
245
316
|
export function defineCrawlCapture(options) {
|
|
246
|
-
const { from, match, key, widths, height, ignore, linkTimeout = 15_000, dir } = options;
|
|
317
|
+
const { from, match, key, widths, height, ignore, variants, liveStates, linkTimeout = 15_000, dir } = options;
|
|
247
318
|
const settings = resolveSettings(options);
|
|
248
319
|
test.describe('styleproof crawl-capture', () => {
|
|
249
320
|
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
@@ -258,28 +329,30 @@ export function defineCrawlCapture(options) {
|
|
|
258
329
|
throw new Error(`styleproof crawl: no links matched at ${from}. The nav must render same-origin ` +
|
|
259
330
|
`<a href> links (a button-only nav exposes nothing to crawl), and \`match\` must keep them.`);
|
|
260
331
|
}
|
|
332
|
+
const captureSurfaces = links.flatMap((link) => expandSurfaceVariants({
|
|
333
|
+
key: link.key,
|
|
334
|
+
go: async (p) => {
|
|
335
|
+
await p.goto(link.url, { waitUntil: 'load' });
|
|
336
|
+
},
|
|
337
|
+
widths,
|
|
338
|
+
ignore,
|
|
339
|
+
height,
|
|
340
|
+
variants,
|
|
341
|
+
liveStates,
|
|
342
|
+
}));
|
|
261
343
|
// Budget the whole sweep up front: one test captures every surface, and
|
|
262
344
|
// captureSurface no longer sets its own timeout, so size it to the work found.
|
|
263
|
-
test.setTimeout(Math.max(180_000,
|
|
345
|
+
test.setTimeout(Math.max(180_000, captureSurfaces.reduce((sum, surface) => sum + (surface.widths?.length ?? 0), 0) * 60_000));
|
|
264
346
|
// 2. Capture each discovered surface. Aggregate failures so one bad surface
|
|
265
347
|
// reports without skipping the rest — they're an independent set, not a chain.
|
|
266
348
|
const failures = [];
|
|
267
|
-
for (const
|
|
268
|
-
for (const width of widths) {
|
|
269
|
-
const surface = {
|
|
270
|
-
key: link.key,
|
|
271
|
-
go: async (p) => {
|
|
272
|
-
await p.goto(link.url, { waitUntil: 'load' });
|
|
273
|
-
},
|
|
274
|
-
widths: [width],
|
|
275
|
-
ignore,
|
|
276
|
-
height,
|
|
277
|
-
};
|
|
349
|
+
for (const surface of captureSurfaces) {
|
|
350
|
+
for (const width of surface.widths ?? []) {
|
|
278
351
|
try {
|
|
279
352
|
await captureSurface(page, surface, width, settings);
|
|
280
353
|
}
|
|
281
354
|
catch (e) {
|
|
282
|
-
failures.push(`${
|
|
355
|
+
failures.push(`${surface.key} @ ${width}: ${e.message}`);
|
|
283
356
|
}
|
|
284
357
|
}
|
|
285
358
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.2",
|
|
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",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"bench": "npm run build && node bench/gate-speed.mjs",
|
|
66
66
|
"init": "node ./bin/styleproof-init.mjs",
|
|
67
67
|
"prepare": "npm run build && husky",
|
|
68
|
-
"prepublishOnly": "npm run clean && npm run build && npm run typecheck"
|
|
68
|
+
"prepublishOnly": "npm run clean && npm run build && npm run typecheck && npm run lint && npm run format:check && npm test && npm run test:e2e"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"@playwright/test": ">=1.40"
|