styleproof 3.17.0 → 3.18.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 +98 -0
- package/README.md +30 -6
- package/bin/styleproof-capture.mjs +15 -3
- package/bin/styleproof-diff.mjs +9 -0
- package/bin/styleproof-init.mjs +7 -0
- package/bin/styleproof-map.mjs +14 -0
- package/dist/capture-url.js +6 -1
- package/dist/crawl-surfaces.d.ts +5 -1
- package/dist/crawl-surfaces.js +15 -11
- package/dist/crawl.d.ts +35 -0
- package/dist/crawl.js +28 -0
- package/dist/diff.d.ts +26 -0
- package/dist/diff.js +54 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/map-store.d.ts +11 -0
- package/dist/map-store.js +38 -3
- package/dist/report.js +15 -7
- package/dist/runner.d.ts +23 -19
- package/dist/runner.js +94 -54
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,104 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.18.0] - 2026-07-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Crawl coverage guard (`defineCrawlCapture` gains `expected` + `exclude`).** A
|
|
15
|
+
link-crawled SPA can now reconcile its _rendered nav_ against a declared route
|
|
16
|
+
registry, both directions: a rendered link with no `expected` entry fails as a new
|
|
17
|
+
route with no owner, and an `expected` route the nav stopped linking fails as a nav
|
|
18
|
+
regression. For such an app the nav is the route universe, so this is the spec
|
|
19
|
+
guard's list-vs-ledger discipline with the nav as the source of truth. Because the
|
|
20
|
+
link set isn't known until the page renders, the check runs _inside the capture
|
|
21
|
+
test_ (fires when `STYLEMAP_DIR` is set), not in the plain suite like the Next
|
|
22
|
+
guard. `exclude` (`key → reason`) opts out conditionally-rendered links (auth /
|
|
23
|
+
feature-flag) so they can't flake the guard; an `exclude` key in neither `expected`
|
|
24
|
+
nor the rendered nav fails as stale. Opt-in and backward-compatible: omit `expected`
|
|
25
|
+
and the crawl behaves exactly as before (captures what the nav links to, asserts no
|
|
26
|
+
completeness). New pure `crawlCoverageGaps` export for asserting reconciliation
|
|
27
|
+
yourself. README's "protected out of the box" scoped to what's wired per framework.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **`--crawl` now honours the fail-loud contract on unreadable CSS.** Three deviations on
|
|
32
|
+
the crawl path let a page with a cross-origin stylesheet be certified without ever being
|
|
33
|
+
fully looked at:
|
|
34
|
+
- Breakpoint auto-detection swallowed the "unreadable stylesheet" throw and silently swept
|
|
35
|
+
only 1280px — every other band certified unchanged without being rendered. The crawl now
|
|
36
|
+
propagates the throw like every other entry point (`styleproof-capture` one-shot,
|
|
37
|
+
`styleproof-map`); the message advises pinning `--widths` for a cross-origin-CSS page.
|
|
38
|
+
- The coverage verifier read class vocabulary only from _readable_ sheets, so a design
|
|
39
|
+
served cross-origin could pass `--require-full-coverage` with an artificially complete
|
|
40
|
+
verdict. Unreadable sheets are now counted and surfaced as **named residue**: a plain
|
|
41
|
+
crawl prints `N stylesheet(s) unreadable — class coverage not provable against them`, and
|
|
42
|
+
`--require-full-coverage` treats them as residue → exit 4. (`CrawlCoverage` gains an
|
|
43
|
+
`unreadable: string[]` field; purely additive.)
|
|
44
|
+
- `--max-depth` had two different defaults (16 in `CRAWL_DEFAULTS`, 1000 in the CLI). The
|
|
45
|
+
CLI default is now **16 everywhere** — the cap exists to bound append-generator UIs (a
|
|
46
|
+
composer that appends a fresh-identity node per click, which dedup can't terminate); 1000
|
|
47
|
+
made it decorative. Raise with `--max-depth` for a genuinely deeper nest.
|
|
48
|
+
- **The navigable-removal hard-gate now has data out of the box for new scaffolds — and
|
|
49
|
+
says so when it doesn't.** The Action defaults the inventory gate _on_ (3.14.0), but
|
|
50
|
+
inventory _capture_ defaults off, so on a spec that doesn't opt in, no map carries an
|
|
51
|
+
inventory, the diff's `inventory` verdict is `null`, and the gate counted zero removals
|
|
52
|
+
forever — armed, but with no ammunition. `styleproof-init` already scaffolds
|
|
53
|
+
`inventory: true` in the generated capture spec (since 3.15.0's zero-config default), so
|
|
54
|
+
**freshly scaffolded projects get the protection**; existing specs are untouched and stay
|
|
55
|
+
opt-in. To make the mismatch impossible to miss on pre-existing specs, the Action's gate
|
|
56
|
+
step now prints a `::notice::` — _"inventory gate is on but the captured maps carry no
|
|
57
|
+
inventory — set `inventory: true`"_ — instead of silently passing green, and
|
|
58
|
+
`styleproof-diff --json` emits an `inventoryNote` explaining the `null` verdict. Both are
|
|
59
|
+
notices, never failures: a spec that deliberately omits inventory capture keeps working.
|
|
60
|
+
|
|
61
|
+
- **The compatibility guard now keys on the real browser build, not just the Playwright
|
|
62
|
+
npm version.** Each capture records `browser().version()` (the actual Chromium build) in
|
|
63
|
+
its manifest, and `styleproof-diff` / `styleproof-report` refuse to compare two maps whose
|
|
64
|
+
builds differ (exit 2, both builds named). The npm `@playwright/test` version was only a
|
|
65
|
+
proxy: the actual binary can change while it holds constant — a `playwright install`
|
|
66
|
+
re-download after a cache wipe, a different `PLAYWRIGHT_BROWSERS_PATH`, or a CI image
|
|
67
|
+
bump — and two maps captured under different Chromium builds used to pass the guard and
|
|
68
|
+
then diff for real, walling the PR with false diffs the canonicalizer can't absorb.
|
|
69
|
+
Backward compatible: the build is compared only when **both** manifests carry it, so
|
|
70
|
+
bundles cached before this field keep comparing against each other; a build change now
|
|
71
|
+
fires the scaffolded recapture fallback instead of a cross-build compare. Fonts are
|
|
72
|
+
documented as an environment responsibility (too noisy across machines to fingerprint
|
|
73
|
+
cheaply); see the same-environment note.
|
|
74
|
+
|
|
75
|
+
- **A missing map is now refused, not mislabelled as "all new surfaces".** When one dir
|
|
76
|
+
held zero captures while the other held some, `styleproof-diff` used to mark every
|
|
77
|
+
surface `missing` and exit `3` ("only new surfaces") — the Action then rendered the
|
|
78
|
+
whole app as 🆕 new baselines a reviewer could approve wholesale. An empty **base** (a
|
|
79
|
+
restore that "succeeded" into an empty dir, a wrong `--base-dir`, a contributor without
|
|
80
|
+
the pre-push hook) meant approving a possibly fully-regressed head as the baseline; an
|
|
81
|
+
empty **head** (a head capture that produced nothing) meant approving a head that
|
|
82
|
+
rendered zero surfaces. Now: a bundle that claims to exist yet holds zero captures — a
|
|
83
|
+
`styleproof-manifest.json` present alongside no maps, on either side — and any empty
|
|
84
|
+
head exit `2` with their own named causes (`base map missing: restore it from the map
|
|
85
|
+
store or recapture both sides — refusing to treat every surface as new` / `head map
|
|
86
|
+
missing: the head capture produced zero surfaces — recapture the head side; refusing to
|
|
87
|
+
treat every surface as removed/new`), distinct enough that the scaffolded workflow's
|
|
88
|
+
capture-needed fallback is the obvious remedy in CI logs. A truly **bare** base dir (no
|
|
89
|
+
manifest, no maps) still means "no baseline exists yet" — the first-adoption flow where
|
|
90
|
+
the base commit predates the capture spec — and keeps the exit-`3` new-surfaces review
|
|
91
|
+
path. To keep that discrimination sound, `styleproof-map` no longer writes a manifest
|
|
92
|
+
(or uploads) when a capture run produced zero surfaces. `styleproof-report` shares the
|
|
93
|
+
load path and is refused identically, so it can't render the misleading all-new page
|
|
94
|
+
either. Exit `3` keeps its meaning: no baseline for _these_ surfaces — new against an
|
|
95
|
+
existing baseline, or the very first one.
|
|
96
|
+
|
|
97
|
+
### Security
|
|
98
|
+
|
|
99
|
+
- **Surface keys are escaped before they reach the privileged PR comment.** Surface keys
|
|
100
|
+
originate from artifact filenames — attacker-controlled in the fork capture/report split
|
|
101
|
+
— and flow into the bot comment (sliced from `report.md`'s headline). Markdown/HTML
|
|
102
|
+
control characters (`` ` ``, `[`, `]`, `(`, `)`, `<`, `>`, `|`) are now stripped where a
|
|
103
|
+
key is interpolated into the report headline, certification, and summary lines, so a
|
|
104
|
+
crafted key can't inject a link, image, or table into the comment. (Crop filenames were
|
|
105
|
+
already restricted to `[a-z0-9-]`; this is the display-side equivalent.) No code
|
|
106
|
+
execution was possible; this closes a Markdown-injection surface.
|
|
107
|
+
|
|
10
108
|
## [3.17.0] - 2026-07-05
|
|
11
109
|
|
|
12
110
|
### Changed
|
package/README.md
CHANGED
|
@@ -53,9 +53,22 @@ It is useful for:
|
|
|
53
53
|
|
|
54
54
|
The important boundary: StyleProof only certifies states it can reach. If a state
|
|
55
55
|
matters, list it as a surface, variant, popup, live state, or component-catalog
|
|
56
|
-
surface. `expected` turns that inventory into a guard
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
surface. `expected` turns that inventory into a guard: a key it lists that is
|
|
57
|
+
neither captured nor excluded fails as missing coverage instead of silently
|
|
58
|
+
passing. What's guarded depends on how `expected` is fed —
|
|
59
|
+
|
|
60
|
+
- **Next.js:** auto-guarded. `styleproof-init` wires `expected` to the routes it
|
|
61
|
+
discovers, so a new page fails the guard with nothing to keep in sync.
|
|
62
|
+
- **Link-crawled SPAs:** pass `expected` to `defineCrawlCapture` and the crawl
|
|
63
|
+
reconciles it against the _rendered nav_ (the route universe for such an app),
|
|
64
|
+
both directions — a new linked route with no `expected` entry fails, and an
|
|
65
|
+
`expected` route the nav stopped linking fails. This runs inside the capture, so
|
|
66
|
+
it fires when you capture (unlike the Next guard, which runs in your plain suite).
|
|
67
|
+
- **Other frameworks:** point `expected` at your own route registry.
|
|
68
|
+
- **Modals, dropdowns, toasts:** guarded only for the state keys you enumerate in
|
|
69
|
+
`expected` (e.g. `dashboard-dialog-open`) — nothing discovers UI states for you.
|
|
70
|
+
|
|
71
|
+
A surface that exists only on the PR head is still reviewable:
|
|
59
72
|
in review-gate mode it holds the status red until approved, then becomes part of
|
|
60
73
|
the baseline once merged.
|
|
61
74
|
|
|
@@ -195,6 +208,17 @@ defineCrawlCapture({
|
|
|
195
208
|
|
|
196
209
|
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).
|
|
197
210
|
|
|
211
|
+
Pass `expected` (a route registry) to turn the crawl into a coverage guard: the crawl reconciles the rendered link set against it, both directions — a rendered link with no `expected` entry fails as a new route with no owner, and an `expected` route the nav stopped linking fails as a nav regression. For a link-crawled SPA the rendered nav _is_ the route universe, so this is the same list-vs-ledger discipline as the spec guard with the nav as the source of truth. Because the link set isn't known until the page renders, this reconciliation runs _inside the capture test_ — so it fires when you capture (`STYLEMAP_DIR` set), not in every `npm test`, unlike the static Next guard. A link that renders conditionally (behind auth or a feature flag) would otherwise make the guard flaky either direction; list it in `exclude` (`key → reason`) to opt it out visibly — an `exclude` key in neither `expected` nor the rendered nav fails as stale, so the ledger can't rot. Omit `expected` and the crawl keeps its default: capture what the nav links to, assert no completeness.
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
defineCrawlCapture({
|
|
215
|
+
from: '/',
|
|
216
|
+
expected: ['index', 'pricing'], // the routes the nav must link to
|
|
217
|
+
exclude: { admin: 'feature-flagged, renders only for staff' },
|
|
218
|
+
dir: process.env.STYLEMAP_DIR,
|
|
219
|
+
});
|
|
220
|
+
```
|
|
221
|
+
|
|
198
222
|
**Component inventory: fail when the catalog misses a component.** StyleProof
|
|
199
223
|
cannot render arbitrary component files by itself across frameworks; props,
|
|
200
224
|
providers, loaders, portals, and app shell context are app-owned. What it can do
|
|
@@ -424,7 +448,7 @@ styleproof-capture https://example.com --crawl --out design # maps every reac
|
|
|
424
448
|
styleproof-diff design .styleproof/maps/current # diff the whole surface vs your build
|
|
425
449
|
```
|
|
426
450
|
|
|
427
|
-
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.
|
|
451
|
+
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. Dedup bounds the normal case — controls dedup by selector, surfaces by a structural fingerprint, so a finite UI runs out of new surfaces — and the `--max-depth` cap bounds the pathological one: an append-generator (a composer that appends a fresh-identity node per click) never repeats a fingerprint, so dedup can't stop it; the depth cap (16 by default) does. `--max-depth` / `--max-actions` / `--max-states` are otherwise 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.
|
|
428
452
|
|
|
429
453
|
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. And it's **parallel by default** — `--workers <n>` (default 4) sweeps states concurrently on isolated browser contexts with the exact same surface set as a serial crawl (dedup is shared; children only enter the queue when their parent's sweep completes); `--workers 1` if you want byte-stable dup-key attribution.
|
|
430
454
|
|
|
@@ -543,7 +567,7 @@ is missing or incompatible, CI recaptures both sides in the same pinned
|
|
|
543
567
|
environment before reporting. Correctness wins over a stale cache, but the hot
|
|
544
568
|
path is report-only.
|
|
545
569
|
|
|
546
|
-
> **Same-environment note.** Computed styles depend on the browser build and installed fonts, so maps are only comparable when captured in the same runtime environment. StyleProof records a compatibility key to select the right cached bundle and refuses to compare maps captured under different browser/platform settings; CI then recaptures both sides instead of producing a bogus report.
|
|
570
|
+
> **Same-environment note.** Computed styles depend on the browser build and installed fonts, so maps are only comparable when captured in the same runtime environment. StyleProof records a compatibility key to select the right cached bundle and refuses to compare maps captured under different browser/platform settings; CI then recaptures both sides instead of producing a bogus report. Each capture also records the **real browser build** (`browser().version()`) in its manifest — the npm `@playwright/test` version is only a proxy, and the actual Chromium binary can change while it holds constant (a `playwright install` re-download, a different `PLAYWRIGHT_BROWSERS_PATH`, a CI image bump). When both sides carry it, a differing build refuses to compare (exit 2, both builds named) instead of walling the PR with false diffs. **Installed fonts are your responsibility:** they are noisy across machines (user-installed families, OS updates, and no cheap cross-platform enumeration), so StyleProof does not fingerprint them — capture both sides on the same fonts, which is what CI's pinned image already gives you.
|
|
547
571
|
|
|
548
572
|
**Want the local side-by-side report** (not just a pass/fail diff)? Run `npx
|
|
549
573
|
styleproof-report` after `styleproof-map`; it uses the same inferred base ref and
|
|
@@ -786,7 +810,7 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
|
|
|
786
810
|
|
|
787
811
|
- `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`.
|
|
788
812
|
- `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.
|
|
789
|
-
- `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)
|
|
813
|
+
- `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 reviewable diff — computed-style/DOM/state differences, and equally an unacknowledged inventory removal, an incomplete coverage registry, or an unproven-determinism capture; `2` on a usage/capture error (including a **missing map** — a bundle that claims to exist yet holds zero captures, i.e. a `styleproof-manifest.json` present with no maps, on either side, or a head capture that produced nothing; refused loudly rather than mislabelled as all-new); `3` when only new surfaces are present (no baseline for _those_ surfaces to diff against — new surfaces against an existing baseline, or a base dir with no manifest at all, meaning no baseline was ever captured: the first-adoption review path; approval policy decides whether to gate). A clean run prints `0 changed surfaces across N captured surface(s)`, and `--json` includes `compared`.
|
|
790
814
|
- `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).
|
|
791
815
|
- `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.
|
|
792
816
|
|
|
@@ -43,7 +43,9 @@ whole surface: --crawl
|
|
|
43
43
|
--require-full-coverage
|
|
44
44
|
exit 4 unless every class the page's stylesheets define was
|
|
45
45
|
rendered in a captured surface — the machine check that
|
|
46
|
-
NOTHING in the design was missed (coverage is always printed)
|
|
46
|
+
NOTHING in the design was missed (coverage is always printed).
|
|
47
|
+
An unreadable cross-origin sheet is residue too: its vocabulary
|
|
48
|
+
can't be proven covered, so it also fails the check.
|
|
47
49
|
--setup <file> JSON steps (goto/fill/click/waitFor) run after EVERY fresh
|
|
48
50
|
navigation — how input-gated states (login, unlock) become
|
|
49
51
|
crawlable. \${ENV_VAR} in value/url is read from the
|
|
@@ -122,15 +124,25 @@ async function runCrawl() {
|
|
|
122
124
|
`(${report.actionsTried} actions tried, ${report.skipped} skipped${report.failed.length ? `, ${report.failed.length} capture-failed` : ''})`,
|
|
123
125
|
);
|
|
124
126
|
const cov = report.coverage;
|
|
127
|
+
const unreadable = cov.unreadable ?? [];
|
|
128
|
+
if (unreadable.length > 0) {
|
|
129
|
+
console.log(
|
|
130
|
+
`⚠ coverage: ${unreadable.length} stylesheet(s) unreadable — class coverage not provable against them ` +
|
|
131
|
+
`(cross-origin, no CORS; make them same-origin / CORS-readable, or pin --widths):\n ${unreadable.join(' ')}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
125
134
|
if (cov.missing.length === 0) {
|
|
126
|
-
|
|
135
|
+
if (unreadable.length === 0)
|
|
136
|
+
console.log(`✓ coverage: all ${cov.defined} stylesheet classes rendered in at least one captured surface`);
|
|
127
137
|
} else {
|
|
128
138
|
console.log(
|
|
129
139
|
`⚠ coverage: ${cov.rendered}/${cov.defined} stylesheet classes rendered — ${cov.missing.length} never seen ` +
|
|
130
140
|
`(dead CSS, or a state the crawl could not reach):\n ${cov.missing.join(' ')}`,
|
|
131
141
|
);
|
|
132
|
-
if (opts.requireFullCoverage) process.exit(4);
|
|
133
142
|
}
|
|
143
|
+
// Residue under --require-full-coverage → exit 4: a never-seen class OR an
|
|
144
|
+
// unreadable sheet (whose vocabulary can't be proven covered at all).
|
|
145
|
+
if (opts.requireFullCoverage && (cov.missing.length > 0 || unreadable.length > 0)) process.exit(4);
|
|
134
146
|
} finally {
|
|
135
147
|
await browser.close();
|
|
136
148
|
}
|
package/bin/styleproof-diff.mjs
CHANGED
|
@@ -322,6 +322,15 @@ if (jsonOut)
|
|
|
322
322
|
unacknowledged: inventoryAudit.unexplained.map((i) => i.key),
|
|
323
323
|
staleAcknowledgements: inventoryAudit.staleAllowances,
|
|
324
324
|
},
|
|
325
|
+
// Explain the `inventory: null` so a gate reading this JSON can tell "armed but no
|
|
326
|
+
// data" apart from "audited, nothing removed". Neither map carried inventory → set
|
|
327
|
+
// `inventory: true` in the capture spec (styleproof-init scaffolds it).
|
|
328
|
+
...(inventoryAudit
|
|
329
|
+
? {}
|
|
330
|
+
: {
|
|
331
|
+
inventoryNote:
|
|
332
|
+
'no captured map carried an inventory — set `inventory: true` in the capture spec to arm the navigable-removal gate',
|
|
333
|
+
}),
|
|
325
334
|
},
|
|
326
335
|
null,
|
|
327
336
|
2,
|
package/bin/styleproof-init.mjs
CHANGED
|
@@ -182,6 +182,13 @@ defineCrawlCapture({
|
|
|
182
182
|
dir: process.env.STYLEMAP_DIR,
|
|
183
183
|
// A single-route SPA whose views are ?tab= / client-routed? Keep only those:
|
|
184
184
|
// match: /\\?tab=/,
|
|
185
|
+
// Turn the crawl into a coverage guard: reconcile the rendered nav against a route
|
|
186
|
+
// registry, both directions — a new linked route with no \`expected\` entry fails, and
|
|
187
|
+
// an \`expected\` route the nav stopped linking fails. (Runs inside the capture, so it
|
|
188
|
+
// fires when you capture, not in every test run.) List conditionally-rendered links
|
|
189
|
+
// (auth / feature-flag) in \`exclude\` so they can't flake the guard either direction:
|
|
190
|
+
// expected: ['index', 'pricing'],
|
|
191
|
+
// exclude: { admin: 'feature-flagged, renders only for staff' },
|
|
185
192
|
// Certify menus, dialogs, tabs, and form-error states on every surface as variants:
|
|
186
193
|
// variants: [{ key: 'menu-open', go: async (page) => { await page.getByRole('button', { name: /menu/i }).click(); } }],
|
|
187
194
|
});
|
package/bin/styleproof-map.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
MapStoreError,
|
|
30
30
|
currentGitSha,
|
|
31
31
|
expectedCompatibilityKey,
|
|
32
|
+
isMapFile,
|
|
32
33
|
publishMapBundle,
|
|
33
34
|
restoreMapBundle,
|
|
34
35
|
workingTreeDirty,
|
|
@@ -307,6 +308,19 @@ if (result.error) {
|
|
|
307
308
|
const status = result.status ?? 1;
|
|
308
309
|
if (status === 0) {
|
|
309
310
|
if (!keepHar) removeHarFiles(targetDir);
|
|
311
|
+
// A run that produced ZERO surface maps must not stamp a manifest (or upload):
|
|
312
|
+
// a manifest over an empty bundle would read as "a bundle that claims to exist
|
|
313
|
+
// yet holds nothing" and the diff would refuse it as a missing base map. A bare
|
|
314
|
+
// dir instead means "no baseline yet" — on a first adoption, capturing the base
|
|
315
|
+
// commit that predates the spec legitimately yields zero surfaces, and the diff
|
|
316
|
+
// then takes the exit-3 new-surfaces review path.
|
|
317
|
+
const captured = fs.existsSync(targetDir) ? fs.readdirSync(targetDir).filter(isMapFile).length : 0;
|
|
318
|
+
if (captured === 0) {
|
|
319
|
+
console.error(
|
|
320
|
+
'styleproof-map: 0 surfaces captured — no manifest written; if this is the base side of a first adoption, the diff will treat it as no-baseline',
|
|
321
|
+
);
|
|
322
|
+
process.exit(status);
|
|
323
|
+
}
|
|
310
324
|
try {
|
|
311
325
|
let manifestSha = sha || undefined;
|
|
312
326
|
if (!manifestSha) {
|
package/dist/capture-url.js
CHANGED
|
@@ -26,7 +26,12 @@ const DEFAULTS = {
|
|
|
26
26
|
screenshots: true,
|
|
27
27
|
crawl: false,
|
|
28
28
|
// Exhaustive by default — these are safety backstops, not budgets.
|
|
29
|
-
maxDepth:
|
|
29
|
+
// maxDepth mirrors CRAWL_DEFAULTS.maxDepth (crawl-surfaces.ts): 16 is
|
|
30
|
+
// exhaustive for real UI — no human-navigable surface is 16 clicks from load.
|
|
31
|
+
// The cap exists to bound append-generator UIs (a composer that appends a
|
|
32
|
+
// fresh-identity node per click, which dedup can't terminate); a higher value
|
|
33
|
+
// would make it decorative. Raise with --max-depth for a genuinely deeper nest.
|
|
34
|
+
maxDepth: 16,
|
|
30
35
|
maxActionsPerState: 100000,
|
|
31
36
|
maxStates: 100000,
|
|
32
37
|
resetStorage: true,
|
package/dist/crawl-surfaces.d.ts
CHANGED
|
@@ -44,11 +44,15 @@ export type CrawledSurface = {
|
|
|
44
44
|
};
|
|
45
45
|
/** Did the crawl SEE everything the design styles? `missing` lists classes the
|
|
46
46
|
* page's own stylesheets select on that never appeared in any captured surface —
|
|
47
|
-
* dead CSS, or a state the crawl could not reach.
|
|
47
|
+
* dead CSS, or a state the crawl could not reach. `unreadable` names stylesheets
|
|
48
|
+
* the browser could not parse (cross-origin, no CORS): their class vocabulary is
|
|
49
|
+
* invisible, so coverage cannot be PROVEN against them. Full coverage = empty
|
|
50
|
+
* `missing` AND empty `unreadable`. */
|
|
48
51
|
export type CrawlCoverage = {
|
|
49
52
|
defined: number;
|
|
50
53
|
rendered: number;
|
|
51
54
|
missing: string[];
|
|
55
|
+
unreadable: string[];
|
|
52
56
|
};
|
|
53
57
|
export type CrawlReport = {
|
|
54
58
|
surfaces: CrawledSurface[];
|
package/dist/crawl-surfaces.js
CHANGED
|
@@ -249,12 +249,15 @@ function domShape() {
|
|
|
249
249
|
/* c8 ignore stop */
|
|
250
250
|
/** Runs in the browser: every class name the page's OWN stylesheets select on —
|
|
251
251
|
* the design's defined vocabulary, read from the parsed CSSOM (inline and
|
|
252
|
-
* same-origin sheets
|
|
253
|
-
*
|
|
254
|
-
*
|
|
252
|
+
* same-origin sheets). Coverage is checked against this, so "fully covered" means
|
|
253
|
+
* every class the design styles was seen rendered in at least one captured
|
|
254
|
+
* surface. A cross-origin sheet the browser can't parse is NOT silently skipped:
|
|
255
|
+
* its href is returned in `unreadable` so coverage names it as residue rather than
|
|
256
|
+
* certifying completeness while blind to it. */
|
|
255
257
|
/* c8 ignore start */
|
|
256
258
|
function collectDefinedClasses() {
|
|
257
259
|
const out = new Set();
|
|
260
|
+
const unreadable = [];
|
|
258
261
|
const scan = (rules) => {
|
|
259
262
|
if (!rules)
|
|
260
263
|
return;
|
|
@@ -271,13 +274,13 @@ function collectDefinedClasses() {
|
|
|
271
274
|
};
|
|
272
275
|
for (const sheet of document.styleSheets) {
|
|
273
276
|
try {
|
|
274
|
-
scan(sheet.cssRules);
|
|
277
|
+
scan(sheet.cssRules); // throws for a cross-origin sheet with no CORS
|
|
275
278
|
}
|
|
276
279
|
catch {
|
|
277
|
-
|
|
280
|
+
unreadable.push(sheet.href ?? '<inline>');
|
|
278
281
|
}
|
|
279
282
|
}
|
|
280
|
-
return [...out];
|
|
283
|
+
return { classes: [...out], unreadable };
|
|
281
284
|
}
|
|
282
285
|
/* c8 ignore stop */
|
|
283
286
|
/** Structural fingerprint of the page's CURRENT state. Dedup key for surfaces. */
|
|
@@ -842,16 +845,17 @@ async function discover(page, opts) {
|
|
|
842
845
|
await gotoFresh(page, opts);
|
|
843
846
|
// No widths given? Detect the page's real @media breakpoints (like the
|
|
844
847
|
// one-shot path does) and sweep one width per band — automatically. Detection
|
|
845
|
-
// reads every stylesheet
|
|
846
|
-
//
|
|
848
|
+
// reads every stylesheet and THROWS if one is cross-origin/unreadable: it never
|
|
849
|
+
// silently sweeps a single width, which would certify every other band
|
|
850
|
+
// unchanged without looking at it. Pin `--widths` for a cross-origin-CSS page.
|
|
847
851
|
if (opts.widths.length === 0) {
|
|
848
|
-
const widths = await detectViewportWidths(page)
|
|
852
|
+
const widths = await detectViewportWidths(page);
|
|
849
853
|
opts = { ...opts, widths };
|
|
850
854
|
if ((widths[0] ?? 1280) !== 1280)
|
|
851
855
|
await gotoFresh(page, opts); // re-pin discovery width BEFORE the base fingerprint
|
|
852
856
|
}
|
|
853
857
|
page.off('request', onRequest);
|
|
854
|
-
const defined = await page.evaluate(collectDefinedClasses)
|
|
858
|
+
const { classes: defined, unreadable } = await page.evaluate(collectDefinedClasses);
|
|
855
859
|
const fp = await fingerprint(page);
|
|
856
860
|
const st = {
|
|
857
861
|
seen: new Set([fp.sig]),
|
|
@@ -881,7 +885,7 @@ async function discover(page, opts) {
|
|
|
881
885
|
skipped: counters.skipped,
|
|
882
886
|
captured: st.captured,
|
|
883
887
|
failed: st.failed,
|
|
884
|
-
coverage: { defined: defined.length, rendered: defined.length - missing.length, missing },
|
|
888
|
+
coverage: { defined: defined.length, rendered: defined.length - missing.length, missing, unreadable },
|
|
885
889
|
};
|
|
886
890
|
}
|
|
887
891
|
/**
|
package/dist/crawl.d.ts
CHANGED
|
@@ -60,3 +60,38 @@ export declare function defaultLinkKey(url: URL): string;
|
|
|
60
60
|
* the capture order is the nav's order — stable across runs.
|
|
61
61
|
*/
|
|
62
62
|
export declare function selectCrawlLinks(hrefs: Iterable<string | null | undefined>, opts: SelectLinksOptions): CrawlLink[];
|
|
63
|
+
/**
|
|
64
|
+
* The reconciliation of a rendered nav (the crawl's discovered link keys) against a
|
|
65
|
+
* declared `expected` universe, both directions. Where the spec guard treats the
|
|
66
|
+
* hand-listed `surfaces` as what's captured, here the crawl's DISCOVERED links are —
|
|
67
|
+
* the nav is the route universe for a link-crawled SPA, so it is the source of truth.
|
|
68
|
+
*
|
|
69
|
+
* - `missing`: an `expected` key with no rendered link and no `exclude` entry — a
|
|
70
|
+
* nav-regression (a route the app promised is no longer linked).
|
|
71
|
+
* - `unexpected`: a rendered link with no `expected` entry and no `exclude` entry — a
|
|
72
|
+
* new route/view with no owner in the registry.
|
|
73
|
+
* - `staleExclusions`: an `exclude` key absent from BOTH `expected` and the rendered
|
|
74
|
+
* set — a rotted opt-out.
|
|
75
|
+
*
|
|
76
|
+
* Unlike {@link CoverageGaps} (which permits captured-not-expected so a spec can
|
|
77
|
+
* tighten its registry over time), the crawl asserts BOTH directions strictly: the
|
|
78
|
+
* rendered link set is complete by construction, so an unowned link is a real gap.
|
|
79
|
+
* Pure and browser-free so it's unit-testable; {@link import('./runner.js')} wraps it
|
|
80
|
+
* in the crawl capture test, where the link set is finally known.
|
|
81
|
+
*/
|
|
82
|
+
export type CrawlCoverageGaps = {
|
|
83
|
+
/** Expected keys with no rendered link and no `exclude` — a nav regression. */
|
|
84
|
+
missing: string[];
|
|
85
|
+
/** Rendered link keys absent from `expected` and `exclude` — a route with no owner. */
|
|
86
|
+
unexpected: string[];
|
|
87
|
+
/** `exclude` keys in neither `expected` nor the rendered set — a rotted opt-out. */
|
|
88
|
+
staleExclusions: string[];
|
|
89
|
+
};
|
|
90
|
+
export declare function crawlCoverageGaps(discoveredKeys: Iterable<string>, expected: Iterable<string>, exclude?: Record<string, string>): CrawlCoverageGaps;
|
|
91
|
+
/**
|
|
92
|
+
* Reconcile the crawled link set against `expected` (via {@link crawlCoverageGaps}) and
|
|
93
|
+
* render the failure message, or `null` when the nav reconciles. `from` names the crawl
|
|
94
|
+
* root in the message. Kept pure and out of the capture test so the wording is
|
|
95
|
+
* unit-testable and {@link defineCrawlCapture} just throws what this returns.
|
|
96
|
+
*/
|
|
97
|
+
export declare function crawlCoverageError(from: string, discoveredKeys: Iterable<string>, expected: Iterable<string>, exclude?: Record<string, string>): string | null;
|
package/dist/crawl.js
CHANGED
|
@@ -97,3 +97,31 @@ export function selectCrawlLinks(hrefs, opts) {
|
|
|
97
97
|
}
|
|
98
98
|
return out;
|
|
99
99
|
}
|
|
100
|
+
export function crawlCoverageGaps(discoveredKeys, expected, exclude = {}) {
|
|
101
|
+
const discovered = new Set(discoveredKeys);
|
|
102
|
+
const expectedSet = new Set(expected);
|
|
103
|
+
const missing = [...expectedSet].filter((k) => !discovered.has(k) && !(k in exclude));
|
|
104
|
+
const unexpected = [...discovered].filter((k) => !expectedSet.has(k) && !(k in exclude));
|
|
105
|
+
const staleExclusions = Object.keys(exclude).filter((k) => !expectedSet.has(k) && !discovered.has(k));
|
|
106
|
+
return { missing, unexpected, staleExclusions };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Reconcile the crawled link set against `expected` (via {@link crawlCoverageGaps}) and
|
|
110
|
+
* render the failure message, or `null` when the nav reconciles. `from` names the crawl
|
|
111
|
+
* root in the message. Kept pure and out of the capture test so the wording is
|
|
112
|
+
* unit-testable and {@link defineCrawlCapture} just throws what this returns.
|
|
113
|
+
*/
|
|
114
|
+
export function crawlCoverageError(from, discoveredKeys, expected, exclude = {}) {
|
|
115
|
+
const { missing, unexpected, staleExclusions } = crawlCoverageGaps(discoveredKeys, expected, exclude);
|
|
116
|
+
const problems = [];
|
|
117
|
+
if (missing.length)
|
|
118
|
+
problems.push(`nav regression — expected route(s) no longer linked from ${from}: ${missing.join(', ')}. ` +
|
|
119
|
+
`Restore the link, or move the key to \`exclude\` with a reason.`);
|
|
120
|
+
if (unexpected.length)
|
|
121
|
+
problems.push(`new route(s) with no owner — link(s) rendered at ${from} but absent from \`expected\`: ` +
|
|
122
|
+
`${unexpected.join(', ')}. Add each to \`expected\`, or to \`exclude\` with a reason.`);
|
|
123
|
+
if (staleExclusions.length)
|
|
124
|
+
problems.push(`stale \`exclude\` — key(s) in neither \`expected\` nor the rendered nav ` +
|
|
125
|
+
`(renamed or removed?): ${staleExclusions.join(', ')}.`);
|
|
126
|
+
return problems.length ? `styleproof crawl coverage gap:\n${problems.join('\n')}` : null;
|
|
127
|
+
}
|
package/dist/diff.d.ts
CHANGED
|
@@ -9,6 +9,32 @@ export type PropChange = {
|
|
|
9
9
|
before: string;
|
|
10
10
|
after: string;
|
|
11
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* The before dir carries a bundle MANIFEST but ZERO captures while the after dir
|
|
14
|
+
* held some — a restore or capture that claims success yet delivered no maps (a
|
|
15
|
+
* corrupt bundle, a wrong --base-dir pointed at a manifest-only dir). Without
|
|
16
|
+
* this guard every after surface diffs as `missing: 'before'` (exit 3, "only new
|
|
17
|
+
* surfaces") and a whole app of regressions becomes one approvable "🆕 all new"
|
|
18
|
+
* report. The CLIs map this to exit 2 — a hard error, never the rubber-stampable
|
|
19
|
+
* exit 3. A truly BARE base dir (no manifest, no maps) is different: it means
|
|
20
|
+
* "never captured — no baseline exists yet", the first-adoption flow where the
|
|
21
|
+
* base commit predates the capture spec, and it keeps the exit-3 review path.
|
|
22
|
+
* (Both dirs empty stays the plain "no captures found" throw.)
|
|
23
|
+
*/
|
|
24
|
+
export declare class MissingBaseMapError extends Error {
|
|
25
|
+
constructor();
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The mirror case: the AFTER (head) dir held ZERO captures while the before dir
|
|
29
|
+
* held some — a head capture or restore that produced nothing. Without this
|
|
30
|
+
* guard every base surface marks `missing: 'after'`, the CLI's new-surface count
|
|
31
|
+
* (which tallies BOTH directions) exits 3, and a head that rendered nothing
|
|
32
|
+
* becomes an approvable "all new surfaces" report — and, once approved, the
|
|
33
|
+
* next base. Same exit-2 path via the CLIs' existing catch.
|
|
34
|
+
*/
|
|
35
|
+
export declare class MissingHeadMapError extends Error {
|
|
36
|
+
constructor();
|
|
37
|
+
}
|
|
12
38
|
export type Finding = {
|
|
13
39
|
kind: 'dom';
|
|
14
40
|
path: string;
|
package/dist/diff.js
CHANGED
|
@@ -1,8 +1,42 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { loadStyleMap, isUnder } from './capture.js';
|
|
4
|
-
import { isMapFile } from './map-store.js';
|
|
4
|
+
import { isMapFile, MAP_MANIFEST } from './map-store.js';
|
|
5
5
|
import { styleValuesEqual } from './canonicalize.js';
|
|
6
|
+
/**
|
|
7
|
+
* The before dir carries a bundle MANIFEST but ZERO captures while the after dir
|
|
8
|
+
* held some — a restore or capture that claims success yet delivered no maps (a
|
|
9
|
+
* corrupt bundle, a wrong --base-dir pointed at a manifest-only dir). Without
|
|
10
|
+
* this guard every after surface diffs as `missing: 'before'` (exit 3, "only new
|
|
11
|
+
* surfaces") and a whole app of regressions becomes one approvable "🆕 all new"
|
|
12
|
+
* report. The CLIs map this to exit 2 — a hard error, never the rubber-stampable
|
|
13
|
+
* exit 3. A truly BARE base dir (no manifest, no maps) is different: it means
|
|
14
|
+
* "never captured — no baseline exists yet", the first-adoption flow where the
|
|
15
|
+
* base commit predates the capture spec, and it keeps the exit-3 review path.
|
|
16
|
+
* (Both dirs empty stays the plain "no captures found" throw.)
|
|
17
|
+
*/
|
|
18
|
+
export class MissingBaseMapError extends Error {
|
|
19
|
+
constructor() {
|
|
20
|
+
super('base map missing: restore it from the map store or recapture both sides — refusing to treat every surface as new. ' +
|
|
21
|
+
'Next: run styleproof-map --restore --sha <base>, or let CI recapture both sides.');
|
|
22
|
+
this.name = 'MissingBaseMapError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The mirror case: the AFTER (head) dir held ZERO captures while the before dir
|
|
27
|
+
* held some — a head capture or restore that produced nothing. Without this
|
|
28
|
+
* guard every base surface marks `missing: 'after'`, the CLI's new-surface count
|
|
29
|
+
* (which tallies BOTH directions) exits 3, and a head that rendered nothing
|
|
30
|
+
* becomes an approvable "all new surfaces" report — and, once approved, the
|
|
31
|
+
* next base. Same exit-2 path via the CLIs' existing catch.
|
|
32
|
+
*/
|
|
33
|
+
export class MissingHeadMapError extends Error {
|
|
34
|
+
constructor() {
|
|
35
|
+
super('head map missing: the head capture produced zero surfaces — recapture the head side; refusing to treat every surface as removed/new. ' +
|
|
36
|
+
'Next: re-run styleproof-map on the head commit, or let CI recapture both sides.');
|
|
37
|
+
this.name = 'MissingHeadMapError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
6
40
|
function diffProps(propsA, propsB, fallbackA, fallbackB, unsetA, unsetB) {
|
|
7
41
|
const changed = [];
|
|
8
42
|
for (const prop of new Set([...Object.keys(propsA), ...Object.keys(propsB)])) {
|
|
@@ -189,6 +223,25 @@ export function diffStyleMapDirs(dirA, dirB) {
|
|
|
189
223
|
const names = [...new Set([...Object.keys(indexA), ...Object.keys(indexB)])].sort();
|
|
190
224
|
if (names.length === 0)
|
|
191
225
|
throw new Error(`no .json(.gz) captures found in ${dirA} or ${dirB}`);
|
|
226
|
+
// A whole side with zero captures is a missing MAP, not a set of genuinely
|
|
227
|
+
// new/removed surfaces — either way every surface would carry a `missing`
|
|
228
|
+
// marker and the run would read as "all new" (exit 3, approvable). Refuse
|
|
229
|
+
// each direction loudly with its own named cause — with one exception:
|
|
230
|
+
//
|
|
231
|
+
// Base side: only when the dir carries a bundle manifest. Manifest + zero maps
|
|
232
|
+
// means a restore/capture that claims success yet delivered nothing (a corrupt
|
|
233
|
+
// bundle) — breakage. A BARE dir (no manifest either) means no baseline was
|
|
234
|
+
// ever captured — the first-adoption flow, where the recapture fallback checks
|
|
235
|
+
// out a base commit that predates the capture spec. That legitimately yields
|
|
236
|
+
// zero surfaces and must keep the exit-3 "new surfaces, review before
|
|
237
|
+
// baselining" onboarding path, so it falls through.
|
|
238
|
+
if (Object.keys(indexA).length === 0 && fs.existsSync(path.join(dirA, MAP_MANIFEST)))
|
|
239
|
+
throw new MissingBaseMapError();
|
|
240
|
+
// Head side: UNCONDITIONAL (bare or manifest-present). The onboarding
|
|
241
|
+
// asymmetry only exists on the base side — the head is the commit under test,
|
|
242
|
+
// so a head that produced zero captures is always breakage, never a review flow.
|
|
243
|
+
if (Object.keys(indexB).length === 0)
|
|
244
|
+
throw new MissingHeadMapError();
|
|
192
245
|
const surfaces = [];
|
|
193
246
|
const counts = { dom: 0, style: 0, state: 0 };
|
|
194
247
|
let volatile = 0;
|
package/dist/index.d.ts
CHANGED
|
@@ -15,8 +15,8 @@ export { discoverNextRoutes } from './routes.js';
|
|
|
15
15
|
export type { DiscoveredRoute } from './routes.js';
|
|
16
16
|
export { discoverComponentFiles, componentCatalogSurfaces } from './components.js';
|
|
17
17
|
export type { DiscoveredComponent, DiscoverComponentFilesOptions, ComponentCatalogSurfaceOptions, } from './components.js';
|
|
18
|
-
export { selectCrawlLinks, defaultLinkKey } from './crawl.js';
|
|
19
|
-
export type { CrawlLink, LinkMatch, SelectLinksOptions } from './crawl.js';
|
|
18
|
+
export { selectCrawlLinks, defaultLinkKey, crawlCoverageGaps, crawlCoverageError } from './crawl.js';
|
|
19
|
+
export type { CrawlLink, LinkMatch, SelectLinksOptions, CrawlCoverageGaps } from './crawl.js';
|
|
20
20
|
export { harvestStyleVariants } from './variant-crawler.js';
|
|
21
21
|
export type { HarvestAction, HarvestedLiveState, HarvestedRoute, HarvestedVariant, HarvestRoute, HarvestSkip, VariantHarvest, VariantHarvestOptions, } from './variant-crawler.js';
|
|
22
22
|
export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ export { coverageGaps } from './coverage.js';
|
|
|
8
8
|
export { detectViewportWidths, mediaTextWidthBoundaries, widthsFromBoundaries } from './breakpoints.js';
|
|
9
9
|
export { discoverNextRoutes } from './routes.js';
|
|
10
10
|
export { discoverComponentFiles, componentCatalogSurfaces } from './components.js';
|
|
11
|
-
export { selectCrawlLinks, defaultLinkKey } from './crawl.js';
|
|
11
|
+
export { selectCrawlLinks, defaultLinkKey, crawlCoverageGaps, crawlCoverageError } from './crawl.js';
|
|
12
12
|
export { harvestStyleVariants } from './variant-crawler.js';
|
|
13
13
|
export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
|
|
14
14
|
export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
|
package/dist/map-store.d.ts
CHANGED
|
@@ -3,6 +3,10 @@ export declare const DEFAULT_MAP_LABEL = "current";
|
|
|
3
3
|
export declare const DEFAULT_MAP_STORE_BRANCH = "styleproof-maps";
|
|
4
4
|
export declare const DEFAULT_REMOTE = "origin";
|
|
5
5
|
export declare const MAP_MANIFEST = "styleproof-manifest.json";
|
|
6
|
+
/** Sidecar written during a capture run (where a browser handle is in scope) recording
|
|
7
|
+
* the real browser build (`browser().version()`). `writeMapManifest` runs after Playwright
|
|
8
|
+
* has exited — no browser — so it reads the build back from here. Not a surface map. */
|
|
9
|
+
export declare const BROWSER_BUILD_SIDECAR = "styleproof-browser.json";
|
|
6
10
|
/** Bundle files that sit alongside the maps but are NOT surfaces (manifest, coverage
|
|
7
11
|
* ledger, and any future sidecar). Every place that enumerates surface maps must skip
|
|
8
12
|
* these, or a sidecar reads as a phantom "new surface". */
|
|
@@ -21,6 +25,10 @@ export interface MapManifest {
|
|
|
21
25
|
lockfile?: string;
|
|
22
26
|
lockfileHash?: string;
|
|
23
27
|
playwrightVersion?: string;
|
|
28
|
+
/** Real browser build (`browser().version()`), recorded at capture time. The npm
|
|
29
|
+
* `@playwright/test` version can hold constant while this changes (re-download, a
|
|
30
|
+
* different browser store, a CI image bump), so this is what actually gates a compare. */
|
|
31
|
+
browserVersion?: string;
|
|
24
32
|
platform: string;
|
|
25
33
|
arch: string;
|
|
26
34
|
nodeMajor: string;
|
|
@@ -39,6 +47,9 @@ export interface CachedCaptureDirs {
|
|
|
39
47
|
compatibilityKey: string;
|
|
40
48
|
tmpRoot: string;
|
|
41
49
|
}
|
|
50
|
+
/** Record the real browser build into the capture dir. Called from a capture run, where a
|
|
51
|
+
* Playwright browser handle is in scope. Best-effort: a missing version is simply not written. */
|
|
52
|
+
export declare function writeBrowserBuildSidecar(dir: string, browserVersion: string | undefined): void;
|
|
42
53
|
export declare function expectedCompatibilityKey(options?: {
|
|
43
54
|
cwd?: string;
|
|
44
55
|
spec?: string;
|
package/dist/map-store.js
CHANGED
|
@@ -11,11 +11,19 @@ export const DEFAULT_MAP_LABEL = 'current';
|
|
|
11
11
|
export const DEFAULT_MAP_STORE_BRANCH = 'styleproof-maps';
|
|
12
12
|
export const DEFAULT_REMOTE = 'origin';
|
|
13
13
|
export const MAP_MANIFEST = 'styleproof-manifest.json';
|
|
14
|
+
/** Sidecar written during a capture run (where a browser handle is in scope) recording
|
|
15
|
+
* the real browser build (`browser().version()`). `writeMapManifest` runs after Playwright
|
|
16
|
+
* has exited — no browser — so it reads the build back from here. Not a surface map. */
|
|
17
|
+
export const BROWSER_BUILD_SIDECAR = 'styleproof-browser.json';
|
|
14
18
|
const GENERATED_DIRTY_ALLOWLIST = new Set(['next-env.d.ts']);
|
|
15
19
|
/** Bundle files that sit alongside the maps but are NOT surfaces (manifest, coverage
|
|
16
20
|
* ledger, and any future sidecar). Every place that enumerates surface maps must skip
|
|
17
21
|
* these, or a sidecar reads as a phantom "new surface". */
|
|
18
|
-
export const RESERVED_BUNDLE_FILES = new Set([
|
|
22
|
+
export const RESERVED_BUNDLE_FILES = new Set([
|
|
23
|
+
MAP_MANIFEST,
|
|
24
|
+
COVERAGE_LEDGER,
|
|
25
|
+
BROWSER_BUILD_SIDECAR,
|
|
26
|
+
]);
|
|
19
27
|
/** True for a captured surface map (`<key>@<width>.json[.gz]`), false for metadata. */
|
|
20
28
|
export function isMapFile(name) {
|
|
21
29
|
return !RESERVED_BUNDLE_FILES.has(name) && /\.json(\.gz)?$/.test(name);
|
|
@@ -71,6 +79,23 @@ function detectLockfile(cwd) {
|
|
|
71
79
|
}
|
|
72
80
|
return {};
|
|
73
81
|
}
|
|
82
|
+
/** Record the real browser build into the capture dir. Called from a capture run, where a
|
|
83
|
+
* Playwright browser handle is in scope. Best-effort: a missing version is simply not written. */
|
|
84
|
+
export function writeBrowserBuildSidecar(dir, browserVersion) {
|
|
85
|
+
if (!browserVersion)
|
|
86
|
+
return;
|
|
87
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
88
|
+
fs.writeFileSync(path.join(dir, BROWSER_BUILD_SIDECAR), JSON.stringify({ browserVersion }, null, 2));
|
|
89
|
+
}
|
|
90
|
+
function readBrowserBuildSidecar(dir) {
|
|
91
|
+
try {
|
|
92
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(dir, BROWSER_BUILD_SIDECAR), 'utf8'));
|
|
93
|
+
return parsed.browserVersion;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
74
99
|
function hasHar(dir) {
|
|
75
100
|
if (!fs.existsSync(dir))
|
|
76
101
|
return false;
|
|
@@ -137,6 +162,7 @@ export function remoteExists(remote = DEFAULT_REMOTE, cwd = process.cwd()) {
|
|
|
137
162
|
export function writeMapManifest(options) {
|
|
138
163
|
const cwd = options.cwd ?? process.cwd();
|
|
139
164
|
const input = compatibilityInput({ cwd, spec: options.spec, baseUrl: options.env?.BASE_URL ?? process.env.BASE_URL });
|
|
165
|
+
const browserVersion = readBrowserBuildSidecar(options.dir);
|
|
140
166
|
const manifest = {
|
|
141
167
|
version: 1,
|
|
142
168
|
packageVersion: input.packageVersion,
|
|
@@ -147,6 +173,7 @@ export function writeMapManifest(options) {
|
|
|
147
173
|
...(input.lockfile ? { lockfile: input.lockfile } : {}),
|
|
148
174
|
...(input.lockfileHash ? { lockfileHash: input.lockfileHash } : {}),
|
|
149
175
|
...(input.playwrightVersion ? { playwrightVersion: input.playwrightVersion } : {}),
|
|
176
|
+
...(browserVersion ? { browserVersion } : {}),
|
|
150
177
|
platform: input.platform,
|
|
151
178
|
arch: input.arch,
|
|
152
179
|
nodeMajor: input.nodeMajor,
|
|
@@ -186,12 +213,20 @@ export function assertCompatibleMapDirs(beforeDir, afterDir) {
|
|
|
186
213
|
playwrightVersion: after.playwrightVersion ?? '',
|
|
187
214
|
baseUrl: after.baseUrl ?? '',
|
|
188
215
|
};
|
|
216
|
+
// Browser build is the actual renderer, but it's optional: only compare when BOTH sides
|
|
217
|
+
// carry it, so bundles cached before this field existed stay comparable to each other.
|
|
218
|
+
// A field on one side only can't be a proven mismatch.
|
|
219
|
+
if (before.browserVersion && after.browserVersion) {
|
|
220
|
+
beforeRuntime.browserVersion = before.browserVersion;
|
|
221
|
+
afterRuntime.browserVersion = after.browserVersion;
|
|
222
|
+
}
|
|
189
223
|
if (JSON.stringify(beforeRuntime) === JSON.stringify(afterRuntime))
|
|
190
224
|
return;
|
|
225
|
+
const build = (m) => (m.browserVersion ? `, browser ${m.browserVersion}` : '');
|
|
191
226
|
throw new MapStoreError([
|
|
192
227
|
'maps were captured in different runtime environments',
|
|
193
|
-
`before ${before.sha.slice(0, 12)}: ${before.compatibilityKey} (${before.platform}/${before.arch}, Playwright ${before.playwrightVersion ?? 'unknown'})`,
|
|
194
|
-
`after ${after.sha.slice(0, 12)}: ${after.compatibilityKey} (${after.platform}/${after.arch}, Playwright ${after.playwrightVersion ?? 'unknown'})`,
|
|
228
|
+
`before ${before.sha.slice(0, 12)}: ${before.compatibilityKey} (${before.platform}/${before.arch}, Playwright ${before.playwrightVersion ?? 'unknown'}${build(before)})`,
|
|
229
|
+
`after ${after.sha.slice(0, 12)}: ${after.compatibilityKey} (${after.platform}/${after.arch}, Playwright ${after.playwrightVersion ?? 'unknown'}${build(after)})`,
|
|
195
230
|
'Next: rebuild one side with styleproof-map in the same environment, or let CI recapture both maps.',
|
|
196
231
|
].join('\n'));
|
|
197
232
|
}
|
package/dist/report.js
CHANGED
|
@@ -385,6 +385,14 @@ export function prettyLabel(p, cls) {
|
|
|
385
385
|
const first = cls.split(/\s+/)[0] ?? '';
|
|
386
386
|
return /^[a-z][a-z0-9-]*$/.test(first) ? `${tag}.${first}` : tag;
|
|
387
387
|
}
|
|
388
|
+
// Surface keys originate from artifact filenames — attacker-controlled in the
|
|
389
|
+
// fork capture/report split, and they flow into the PRIVILEGED PR-comment summary
|
|
390
|
+
// (the Action slices report.md above the first `### `). Strip the Markdown/HTML
|
|
391
|
+
// control characters (`` ` ``, [ ] ( ), < >, |) that could inject a link, image,
|
|
392
|
+
// or table into that bot comment. Escaping at the render boundary — the keys stay
|
|
393
|
+
// legible; only the injection surface is removed. (Crop FILENAMES are separately
|
|
394
|
+
// restricted to [a-z0-9-]; this is the display-side equivalent.)
|
|
395
|
+
const safeKey = (s) => s.replace(/[`[\]()<>|]/g, '-');
|
|
388
396
|
const surfaceBase = (s) => s.replace(/@\d+$/, '');
|
|
389
397
|
const surfaceWidth = (s) => Number(s.match(/@(\d+)$/)?.[1] ?? 0);
|
|
390
398
|
function pushSurfaceWidth(byBase, base, surface) {
|
|
@@ -396,7 +404,7 @@ function renderSurfaceGroups(byBase) {
|
|
|
396
404
|
return [...byBase]
|
|
397
405
|
.map(([base, ws]) => {
|
|
398
406
|
const widths = ws.filter((w) => w > 0).sort((a, b) => b - a);
|
|
399
|
-
return widths.length ? `${base} @ ${widths.join(', ')}` : base;
|
|
407
|
+
return widths.length ? `${safeKey(base)} @ ${widths.join(', ')}` : safeKey(base);
|
|
400
408
|
})
|
|
401
409
|
.join(' · ');
|
|
402
410
|
}
|
|
@@ -821,7 +829,7 @@ function renderContentSurface(ctx, surface, changes, seq) {
|
|
|
821
829
|
const mapB = loadStyleMap(findCapture(ctx.afterDir, surface));
|
|
822
830
|
const pngA = readPng(path.join(ctx.beforeDir, `${surface}.png`));
|
|
823
831
|
const pngB = readPng(path.join(ctx.afterDir, `${surface}.png`));
|
|
824
|
-
const md = ['', `### \`${surface}\` · ${changes.length} content change(s)`];
|
|
832
|
+
const md = ['', `### \`${safeKey(surface)}\` · ${changes.length} content change(s)`];
|
|
825
833
|
for (const c of changes) {
|
|
826
834
|
seq++;
|
|
827
835
|
md.push('', `**\`${prettyLabel(c.path, c.cls)}\`**`, '', `- before: \`${clipText(c.before) || '(empty)'}\``, `- after: \`${clipText(c.after) || '(empty)'}\``, ...contentCropLines(ctx, surface, c, mapA, mapB, pngA, pngB, seq));
|
|
@@ -888,7 +896,7 @@ function coverageLine(cov) {
|
|
|
888
896
|
if (cov.basis === 'complete')
|
|
889
897
|
return `- **Coverage** — ✓ complete (all ${cov.registrySize} registered surface(s) captured)`;
|
|
890
898
|
if (cov.basis === 'incomplete')
|
|
891
|
-
return `- **Coverage** — ✗ INCOMPLETE (${cov.uncovered.length} registered surface(s) not captured: ${cov.uncovered.join(', ')})`;
|
|
899
|
+
return `- **Coverage** — ✗ INCOMPLETE (${cov.uncovered.length} registered surface(s) not captured: ${cov.uncovered.map(safeKey).join(', ')})`;
|
|
892
900
|
return '- **Coverage** — ⚠ not asserted (no `expected` registry; certifies only the captured surfaces)';
|
|
893
901
|
}
|
|
894
902
|
function determinismLine(det) {
|
|
@@ -900,7 +908,7 @@ function determinismLine(det) {
|
|
|
900
908
|
}
|
|
901
909
|
function inventoryLine(inv) {
|
|
902
910
|
if (inv.unexplained.length > 0) {
|
|
903
|
-
const keys = inv.unexplained.map((i) => i.key);
|
|
911
|
+
const keys = inv.unexplained.map((i) => safeKey(i.key));
|
|
904
912
|
return `- **Inventory** — ⚠ ${inv.unexplained.length} navigable affordance(s) removed, unacknowledged: ${keys.slice(0, 8).join(', ')}${keys.length > 8 ? ', …' : ''}`;
|
|
905
913
|
}
|
|
906
914
|
if (inv.delta.removed.length > 0)
|
|
@@ -1176,7 +1184,7 @@ function renderNewSurface(p, ctx, cropSeq) {
|
|
|
1176
1184
|
const png = readPng(path.join(srcDir, `${p.sd.surface}.png`));
|
|
1177
1185
|
const md = [
|
|
1178
1186
|
'',
|
|
1179
|
-
`### \`${p.sd.surface}\` · new surface ${NEW_SURFACE_MARKER}`,
|
|
1187
|
+
`### \`${safeKey(p.sd.surface)}\` · new surface ${NEW_SURFACE_MARKER}`,
|
|
1180
1188
|
'',
|
|
1181
1189
|
`_${formatSurfaceWithContext(p.sd.surface, map)}_`,
|
|
1182
1190
|
];
|
|
@@ -1214,7 +1222,7 @@ function cappedNoticeLines(budget) {
|
|
|
1214
1222
|
* name (and how many surfaces share the identical change) · change count · a crop
|
|
1215
1223
|
* link so the reviewer can still see it without opening report.json. */
|
|
1216
1224
|
function compactChangeSummary(cg, json, img) {
|
|
1217
|
-
const surface = cg.rep.sd.surface;
|
|
1225
|
+
const surface = safeKey(cg.rep.sd.surface);
|
|
1218
1226
|
const more = cg.surfaces.length > 1 ? ` (+${cg.surfaces.length - 1} more)` : '';
|
|
1219
1227
|
const regions = json.regions ?? [];
|
|
1220
1228
|
const composite = regions[0]?.images?.composite;
|
|
@@ -1317,7 +1325,7 @@ export function generateStyleMapReport(opts) {
|
|
|
1317
1325
|
const r = renderNewSurface(p, ctx, cropSeq);
|
|
1318
1326
|
json.push(r.json);
|
|
1319
1327
|
cropSeq = r.cropSeq;
|
|
1320
|
-
emitDetail(r.md, `- \`${p.sd.surface}\` · new surface`);
|
|
1328
|
+
emitDetail(r.md, `- \`${safeKey(p.sd.surface)}\` · new surface`);
|
|
1321
1329
|
}
|
|
1322
1330
|
md.push(...contentSection.md);
|
|
1323
1331
|
const reportMdPath = path.join(outDir, 'report.md');
|
package/dist/runner.d.ts
CHANGED
|
@@ -255,25 +255,29 @@ export type CrawlOptions = CaptureConfig & {
|
|
|
255
255
|
/** Max ms to wait for the crawl root's links to render before reading them
|
|
256
256
|
* (an SPA hydrates its nav client-side). Default 15000. */
|
|
257
257
|
linkTimeout?: number;
|
|
258
|
+
/**
|
|
259
|
+
* The full set of surface keys the app knows its nav should link to — its route
|
|
260
|
+
* universe. When set, the crawl reconciles the DISCOVERED link set against it, both
|
|
261
|
+
* directions: an `expected` key with no rendered link fails (nav regression), and a
|
|
262
|
+
* rendered link with no `expected` entry fails (a new route with no owner). For a
|
|
263
|
+
* link-crawled SPA the rendered nav IS the route universe, so this is the same
|
|
264
|
+
* list-vs-ledger discipline as `defineStyleMapCapture`'s guard with the nav as the
|
|
265
|
+
* source of truth.
|
|
266
|
+
*
|
|
267
|
+
* Unlike the spec guard, this runs INSIDE the crawl capture test — the link set
|
|
268
|
+
* isn't known until a browser renders the page — so it only fires when the capture
|
|
269
|
+
* runs (STYLEMAP_DIR set), not in every `npm test`. Omit to keep the current
|
|
270
|
+
* behaviour: capture what the nav links to, assert no completeness.
|
|
271
|
+
*/
|
|
272
|
+
expected?: string[];
|
|
273
|
+
/**
|
|
274
|
+
* Expected/rendered keys deliberately not reconciled, each mapped to its reason — a
|
|
275
|
+
* visible, reviewed opt-out ledger for links that render CONDITIONALLY (behind auth
|
|
276
|
+
* or a feature flag) and so can't be asserted present or absent on every run. An
|
|
277
|
+
* excluded key never triggers a missing- or unexpected-link failure; an `exclude`
|
|
278
|
+
* key in neither `expected` nor the rendered set fails, so the ledger can't rot.
|
|
279
|
+
*/
|
|
280
|
+
exclude?: Record<string, string>;
|
|
258
281
|
};
|
|
259
|
-
/**
|
|
260
|
-
* Like {@link defineStyleMapCapture}, but the surface set is DISCOVERED at run time
|
|
261
|
-
* by crawling a page's links instead of being hand-listed — for a single-route SPA
|
|
262
|
-
* whose views are `?tab=`/client-routed and so invisible to the filesystem
|
|
263
|
-
* {@link discoverNextRoutes}. It navigates `from`, reads its same-origin `<a href>`s
|
|
264
|
-
* (filtered by `match`), and captures each as a surface keyed by `key`. The app just
|
|
265
|
-
* has to render its nav as real links; nothing to hand-maintain, so the surface list
|
|
266
|
-
* can't drift from the nav.
|
|
267
|
-
*
|
|
268
|
-
* One Playwright test does the whole sweep (the link set isn't known until a browser
|
|
269
|
-
* has rendered the page, so per-surface tests can't be generated at collection time).
|
|
270
|
-
* Per-surface failures are aggregated — one bad surface reports without hiding the
|
|
271
|
-
* rest. Replay/self-check/clock-freeze behave exactly as for explicit surfaces.
|
|
272
|
-
*
|
|
273
|
-
* ```ts
|
|
274
|
-
* // styleproof.spec.ts — capture every tab the nav links to
|
|
275
|
-
* defineCrawlCapture({ from: '/', match: /\?tab=/, widths: [1440, 1024, 768], dir: process.env.STYLEMAP_DIR });
|
|
276
|
-
* ```
|
|
277
|
-
*/
|
|
278
282
|
export declare function defineCrawlCapture(options: CrawlOptions): void;
|
|
279
283
|
export {};
|
package/dist/runner.js
CHANGED
|
@@ -4,8 +4,9 @@ import path from 'node:path';
|
|
|
4
4
|
import { captureStyleMap, saveStyleMap, trackInflightRequests, } from './capture.js';
|
|
5
5
|
import { diffStyleMaps } from './diff.js';
|
|
6
6
|
import { coverageGaps, COVERAGE_LEDGER } from './coverage.js';
|
|
7
|
+
import { writeBrowserBuildSidecar } from './map-store.js';
|
|
7
8
|
import { detectViewportWidths } from './breakpoints.js';
|
|
8
|
-
import { selectCrawlLinks } from './crawl.js';
|
|
9
|
+
import { selectCrawlLinks, crawlCoverageError } from './crawl.js';
|
|
9
10
|
/** One-line description of the first drift finding, for the self-check error. */
|
|
10
11
|
function driftDesc(f) {
|
|
11
12
|
if (f.kind === 'dom')
|
|
@@ -473,6 +474,17 @@ function writeCoverageLedgerTest(settings, dir, expected, exclude) {
|
|
|
473
474
|
fs.writeFileSync(path.join(outDir, COVERAGE_LEDGER), JSON.stringify(ledger, null, 2));
|
|
474
475
|
});
|
|
475
476
|
}
|
|
477
|
+
/** Record the real browser build into the capture bundle. The npm `@playwright/test`
|
|
478
|
+
* version (in the manifest) is only a proxy — the actual Chromium binary can change while
|
|
479
|
+
* it holds constant (a re-download, a different browser store, a CI image bump). The
|
|
480
|
+
* compatibility guard reads this back to refuse a cross-build compare instead of walling
|
|
481
|
+
* a PR with false diffs. Best-effort: an unavailable version leaves the guard as-is. */
|
|
482
|
+
function writeBrowserBuildTest(settings, dir) {
|
|
483
|
+
test('styleproof browser build', ({ page }) => {
|
|
484
|
+
const version = page.context().browser()?.version();
|
|
485
|
+
writeBrowserBuildSidecar(path.join(settings.baseDir, dir), version);
|
|
486
|
+
});
|
|
487
|
+
}
|
|
476
488
|
export function defineStyleMapCapture(options) {
|
|
477
489
|
const { surfaces, expected, exclude = {}, dir } = options;
|
|
478
490
|
const settings = resolveSettings(options);
|
|
@@ -498,6 +510,8 @@ export function defineStyleMapCapture(options) {
|
|
|
498
510
|
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
499
511
|
if (dir)
|
|
500
512
|
writeCoverageLedgerTest(settings, dir, expected ?? null, exclude);
|
|
513
|
+
if (dir)
|
|
514
|
+
writeBrowserBuildTest(settings, dir);
|
|
501
515
|
for (const surface of captureSurfaces) {
|
|
502
516
|
if (surface.widths && surface.widths.length > 0) {
|
|
503
517
|
// Explicit widths: one parallelizable test per surface × width.
|
|
@@ -541,38 +555,91 @@ export function defineStyleMapCapture(options) {
|
|
|
541
555
|
* defineCrawlCapture({ from: '/', match: /\?tab=/, widths: [1440, 1024, 768], dir: process.env.STYLEMAP_DIR });
|
|
542
556
|
* ```
|
|
543
557
|
*/
|
|
558
|
+
/**
|
|
559
|
+
* Load the crawl root, wait for its nav links to hydrate, and read them into a
|
|
560
|
+
* deduped, keyed surface list. A link-less page is fine for an unfiltered crawl —
|
|
561
|
+
* it still captures `from` itself (includeSelf); a `match`-filtered crawl genuinely
|
|
562
|
+
* needs links, so its hydration timeout surfaces. Throws when nothing matched.
|
|
563
|
+
*/
|
|
564
|
+
async function discoverCrawlLinks(page, { from, match, key, linkTimeout }) {
|
|
565
|
+
await page.goto(from, { waitUntil: 'load' });
|
|
566
|
+
await page.waitForSelector('a[href]', { timeout: linkTimeout }).catch((e) => {
|
|
567
|
+
if (match !== undefined)
|
|
568
|
+
throw e;
|
|
569
|
+
});
|
|
570
|
+
const hrefs = await page.$$eval('a[href]', (els) => els.map((e) => e.getAttribute('href')));
|
|
571
|
+
// Unfiltered crawl → also capture `from` itself, so the root is always covered and
|
|
572
|
+
// a single-page (or no-nav-self-link) app still yields a surface. A `match`-filtered
|
|
573
|
+
// crawl captures only the links the caller asked for.
|
|
574
|
+
const links = selectCrawlLinks(hrefs, { base: page.url(), match, key, includeSelf: match === undefined });
|
|
575
|
+
if (links.length === 0) {
|
|
576
|
+
throw new Error(`styleproof crawl: no links matched at ${from}. The nav must render same-origin ` +
|
|
577
|
+
`<a href> links (a button-only nav exposes nothing to crawl), and \`match\` must keep them.`);
|
|
578
|
+
}
|
|
579
|
+
return links;
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Capture every discovered surface, aggregating per-surface failures so one bad
|
|
583
|
+
* surface reports without skipping the rest — they're an independent set, not a
|
|
584
|
+
* chain. Auto-width parity with explicit surfaces: no widths given → navigate once,
|
|
585
|
+
* detect the surface's @media bands, then sweep one viewport per band.
|
|
586
|
+
*/
|
|
587
|
+
async function sweepCrawlSurfaces(page, captureSurfaces, settings) {
|
|
588
|
+
const failures = [];
|
|
589
|
+
for (const surface of captureSurfaces) {
|
|
590
|
+
let sweep = surface.widths;
|
|
591
|
+
if (!sweep || sweep.length === 0) {
|
|
592
|
+
try {
|
|
593
|
+
await surface.go(page);
|
|
594
|
+
sweep = await detectViewportWidths(page);
|
|
595
|
+
}
|
|
596
|
+
catch (e) {
|
|
597
|
+
failures.push(`${surface.key} @ auto: ${e.message}`);
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
for (const width of sweep) {
|
|
602
|
+
try {
|
|
603
|
+
await captureSurface(page, surface, width, settings);
|
|
604
|
+
}
|
|
605
|
+
catch (e) {
|
|
606
|
+
failures.push(`${surface.key} @ ${width}: ${e.message}`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
if (failures.length) {
|
|
611
|
+
throw new Error(`styleproof crawl-capture: ${failures.length} surface(s) failed:\n${failures.join('\n')}`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
544
614
|
export function defineCrawlCapture(options) {
|
|
545
|
-
const { from, match, key, widths, height, ignore, variants, liveStates, popups, linkTimeout = 15_000, dir, settle, } = options;
|
|
615
|
+
const { from, match, key, widths, height, ignore, variants, liveStates, popups, linkTimeout = 15_000, dir, settle, expected, exclude = {}, } = options;
|
|
546
616
|
const settings = resolveSettings(options);
|
|
547
617
|
// Title contains "styleproof capture" so the same `--grep 'styleproof capture'`
|
|
548
618
|
// that styleproof-map uses to select capture tests picks up crawl specs too.
|
|
549
619
|
test.describe('styleproof capture (crawl)', () => {
|
|
550
620
|
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
551
|
-
//
|
|
552
|
-
// so it records `expected: null` (
|
|
553
|
-
// captures what the nav links to, and can't prove that's every route
|
|
621
|
+
// Record the completeness basis. Without `expected` a crawl has no registry to
|
|
622
|
+
// check against, so it records `expected: null` (honestly "not asserted": it
|
|
623
|
+
// captures what the nav links to, and can't prove that's every route). With
|
|
624
|
+
// `expected` the crawl reconciles the DISCOVERED link set against it below and the
|
|
625
|
+
// ledger travels with the declared universe.
|
|
626
|
+
if (dir)
|
|
627
|
+
writeCoverageLedgerTest(settings, dir, expected ?? null, exclude);
|
|
554
628
|
if (dir)
|
|
555
|
-
|
|
629
|
+
writeBrowserBuildTest(settings, dir);
|
|
556
630
|
test('discover surfaces by crawling links, then capture each', async ({ page }) => {
|
|
557
|
-
// 1. Load the root and
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
// a single-page (or no-nav-self-link) app still yields a surface. A `match`-filtered
|
|
570
|
-
// crawl captures only the links the caller asked for.
|
|
571
|
-
const links = selectCrawlLinks(hrefs, { base: page.url(), match, key, includeSelf: match === undefined });
|
|
572
|
-
if (links.length === 0) {
|
|
573
|
-
throw new Error(`styleproof crawl: no links matched at ${from}. The nav must render same-origin ` +
|
|
574
|
-
`<a href> links (a button-only nav exposes nothing to crawl), and \`match\` must keep them.`);
|
|
575
|
-
}
|
|
631
|
+
// 1. Load the root and read its hydrated nav links into the surface set.
|
|
632
|
+
const links = await discoverCrawlLinks(page, { from, match, key, linkTimeout });
|
|
633
|
+
// Coverage guard for a crawled nav. The rendered link set IS the route universe
|
|
634
|
+
// for a link-crawled SPA, so reconciling it against `expected` (both directions)
|
|
635
|
+
// is the spec guard's list-vs-ledger discipline with the nav as source of truth.
|
|
636
|
+
// Runs here — inside the capture test — because the link set isn't known until
|
|
637
|
+
// the page renders (unlike the static spec guard, which runs in the plain suite).
|
|
638
|
+
const gap = expected
|
|
639
|
+
? crawlCoverageError(from, links.map((l) => l.key), expected, exclude)
|
|
640
|
+
: null;
|
|
641
|
+
if (gap)
|
|
642
|
+
throw new Error(gap);
|
|
576
643
|
const captureSurfaces = links.flatMap((link) => expandSurfaceVariants({
|
|
577
644
|
key: link.key,
|
|
578
645
|
go: async (p) => {
|
|
@@ -592,35 +659,8 @@ export function defineCrawlCapture(options) {
|
|
|
592
659
|
// With auto-width the band count isn't known until each surface renders, so
|
|
593
660
|
// assume up to 4 bands per surface.
|
|
594
661
|
test.setTimeout(Math.max(180_000, captureSurfaces.reduce((sum, surface) => sum + (surface.widths?.length ?? 4), 0) * 60_000));
|
|
595
|
-
// 2. Capture each discovered surface
|
|
596
|
-
|
|
597
|
-
const failures = [];
|
|
598
|
-
for (const surface of captureSurfaces) {
|
|
599
|
-
// Auto-width parity with explicit surfaces: no widths given → navigate once and
|
|
600
|
-
// detect this surface's @media bands, then sweep one viewport per band.
|
|
601
|
-
let sweep = surface.widths;
|
|
602
|
-
if (!sweep || sweep.length === 0) {
|
|
603
|
-
try {
|
|
604
|
-
await surface.go(page);
|
|
605
|
-
sweep = await detectViewportWidths(page);
|
|
606
|
-
}
|
|
607
|
-
catch (e) {
|
|
608
|
-
failures.push(`${surface.key} @ auto: ${e.message}`);
|
|
609
|
-
continue;
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
for (const width of sweep) {
|
|
613
|
-
try {
|
|
614
|
-
await captureSurface(page, surface, width, settings);
|
|
615
|
-
}
|
|
616
|
-
catch (e) {
|
|
617
|
-
failures.push(`${surface.key} @ ${width}: ${e.message}`);
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
if (failures.length) {
|
|
622
|
-
throw new Error(`styleproof crawl-capture: ${failures.length} surface(s) failed:\n${failures.join('\n')}`);
|
|
623
|
-
}
|
|
662
|
+
// 2. Capture each discovered surface, aggregating per-surface failures.
|
|
663
|
+
await sweepCrawlSurfaces(page, captureSurfaces, settings);
|
|
624
664
|
});
|
|
625
665
|
});
|
|
626
666
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.18.0",
|
|
4
4
|
"description": "Catch every CSS change before it ships — review PRs and certify refactors by the browser's computed styles, not pixels. Works with any styling system.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playwright",
|