styleproof 3.7.0 → 3.9.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 +66 -0
- package/bin/styleproof-diff.mjs +71 -9
- package/bin/styleproof-init.mjs +29 -56
- package/dist/coverage.d.ts +23 -0
- package/dist/coverage.js +23 -0
- package/dist/crawl.d.ts +4 -0
- package/dist/crawl.js +5 -0
- package/dist/diff.js +2 -1
- package/dist/map-store.d.ts +6 -0
- package/dist/map-store.js +9 -0
- package/dist/report.js +2 -3
- package/dist/runner.d.ts +17 -15
- package/dist/runner.js +58 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,72 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.9.0] - 2026-07-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Coverage provenance — a green now states its completeness basis.** Source-of-truth
|
|
15
|
+
step 1: a green from `styleproof-diff` used to silently imply completeness it couldn't
|
|
16
|
+
back up (it certified only the surfaces it happened to capture). Now the capture writes
|
|
17
|
+
a coverage ledger (`styleproof-coverage.json`) into the bundle — the declared
|
|
18
|
+
`expected` registry, or `null` — and `styleproof-diff` reads it and:
|
|
19
|
+
- **blocks (exit 1) when a registered surface was never captured**, even if the style
|
|
20
|
+
diff is empty — the one failure the gate couldn't catch before (a green over a
|
|
21
|
+
surface it never looked at). This checks the surfaces actually captured, so a
|
|
22
|
+
declared surface whose capture _failed_ is caught here, where the suite guard (which
|
|
23
|
+
checks the declared list) cannot see it;
|
|
24
|
+
- prints the basis on every run: `✓ coverage complete — all N registered surface(s)
|
|
25
|
+
captured`, `✗ coverage INCOMPLETE — …`, or `⚠ completeness NOT asserted` (no
|
|
26
|
+
registry — certifies only the captured surfaces, so declare `expected` to certify
|
|
27
|
+
completeness). A crawl records `expected: null` honestly (it captures what the nav
|
|
28
|
+
links to, not proven-every-route).
|
|
29
|
+
- `isMapFile` / `RESERVED_BUNDLE_FILES` (map-store) — one place that knows which bundle
|
|
30
|
+
files are surface maps vs metadata sidecars, so a new sidecar can't read as a phantom
|
|
31
|
+
"new surface".
|
|
32
|
+
|
|
33
|
+
## [3.8.0] - 2026-07-05
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- **Zero-config out of the box** — `styleproof-init` now scaffolds a crawl-by-default
|
|
38
|
+
spec for any non-Next.js app: `defineCrawlCapture({ from: '/', settle, inventory: true })`
|
|
39
|
+
captures every surface the nav links to (the root plus each same-origin `<a href>`)
|
|
40
|
+
with **nothing to hand-list** — the surface set is discovered from the rendered nav and
|
|
41
|
+
can't drift from it. Next.js keeps filesystem route discovery; both variants now enable
|
|
42
|
+
the inventory guard by default.
|
|
43
|
+
- **`defineCrawlCapture` auto-width** — omit `widths` and StyleProof detects each
|
|
44
|
+
discovered surface's `@media` breakpoints and sweeps one viewport per band, the same
|
|
45
|
+
zero-config behaviour explicit surfaces already had.
|
|
46
|
+
- **`defineCrawlCapture` `settle` hook** — run an app-specific step (e.g. trigger
|
|
47
|
+
scroll-reveal) after navigating to each crawled surface, for parity with a hand-listed
|
|
48
|
+
surface's `go`.
|
|
49
|
+
- **`inventory` spec option** — `defineStyleMapCapture` / `defineCrawlCapture` now forward
|
|
50
|
+
`inventory: true` to the capture, so turning the inventory guard on (a removed nav item
|
|
51
|
+
fails `styleproof-diff`) is a one-line opt-in from a spec, no manual `captureStyleMap`.
|
|
52
|
+
- The crawl now always covers its `from` root on an unfiltered crawl, so a home page not
|
|
53
|
+
linked in its own nav — or a single-page app with no links — is still captured.
|
|
54
|
+
|
|
55
|
+
### Changed
|
|
56
|
+
|
|
57
|
+
- `styleproof-init` guidance now leads with "it runs on your first PR with no extra
|
|
58
|
+
steps" (CI captures both sides on a cache miss); the local `npx styleproof-map`
|
|
59
|
+
pre-cache is framed as an optional speedup, not a required first step.
|
|
60
|
+
|
|
61
|
+
### Tests / docs
|
|
62
|
+
|
|
63
|
+
- **Dogfood: the "100% surfaced" contract** — `test/pr-surfacing.e2e.spec.ts` runs the
|
|
64
|
+
real capture → diff → report flow for every change class (resting style,
|
|
65
|
+
`:hover/:focus/:active` drop, `::before/::after`, DOM add/remove/retag, a removed nav
|
|
66
|
+
item via the inventory guard, a new surface, and a clean no-op) and asserts each is
|
|
67
|
+
surfaced — the last two levels through the actual `styleproof-diff` / `styleproof-report`
|
|
68
|
+
CLIs. Closes the four classes that previously had no end-to-end proof (`:active` drop,
|
|
69
|
+
DOM removed, retag, pseudo-element change).
|
|
70
|
+
- **Dogfood: zero-config flow** — `test/cli-flow.e2e.spec.ts` runs `styleproof-init` on a
|
|
71
|
+
real multi-page app and proves the generated crawl spec captures every page (root +
|
|
72
|
+
pricing + about), multi-width, with the inventory harvested — no spec editing.
|
|
73
|
+
- **`docs/what-it-catches.md`** — states what StyleProof catches and its honest boundary
|
|
74
|
+
(surfaces it never captured), so a green check is earned, not assumed.
|
|
75
|
+
|
|
10
76
|
## [3.7.0] - 2026-07-04
|
|
11
77
|
|
|
12
78
|
### Added
|
package/bin/styleproof-diff.mjs
CHANGED
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
} from '../dist/cli-errors.js';
|
|
42
42
|
import { loadStyleMap } from '../dist/capture.js';
|
|
43
43
|
import { auditRunInventory } from '../dist/inventory.js';
|
|
44
|
+
import { auditCoverage, COVERAGE_LEDGER } from '../dist/coverage.js';
|
|
45
|
+
import { isMapFile } from '../dist/map-store.js';
|
|
44
46
|
|
|
45
47
|
const COMMAND = path.basename(process.argv[1] ?? 'styleproof-diff').replace(/\.mjs$/, '');
|
|
46
48
|
|
|
@@ -55,7 +57,7 @@ const COMMAND = path.basename(process.argv[1] ?? 'styleproof-diff').replace(/\.m
|
|
|
55
57
|
function dirInventories(dir) {
|
|
56
58
|
return fs
|
|
57
59
|
.readdirSync(dir)
|
|
58
|
-
.filter(
|
|
60
|
+
.filter(isMapFile)
|
|
59
61
|
.map((f) => loadStyleMap(path.join(dir, f)))
|
|
60
62
|
.map((m) => (m.inventory ? { inventory: m.inventory } : {}));
|
|
61
63
|
}
|
|
@@ -110,6 +112,59 @@ function printInventoryAudit(audit) {
|
|
|
110
112
|
return unexplained.length;
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
// ── coverage provenance (the completeness basis of a green) ──────────────────────
|
|
116
|
+
// The head bundle carries a coverage ledger (the declared registry). The gate audits
|
|
117
|
+
// the ACTUALLY-captured surfaces against it, so "clean" states its basis — complete vs
|
|
118
|
+
// the registry, or explicitly "not asserted" — instead of silently implying completeness.
|
|
119
|
+
|
|
120
|
+
// Surface keys captured in a dir (file `<key>@<width>.json[.gz]` → `<key>`, deduped).
|
|
121
|
+
function capturedSurfaceKeys(dir) {
|
|
122
|
+
return [
|
|
123
|
+
...new Set(
|
|
124
|
+
fs
|
|
125
|
+
.readdirSync(dir)
|
|
126
|
+
.filter(isMapFile)
|
|
127
|
+
.map((f) => f.replace(/@\d+\.json(\.gz)?$/, '')),
|
|
128
|
+
),
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function readCoverageVerdict(dir) {
|
|
133
|
+
let ledger = null;
|
|
134
|
+
const p = path.join(dir, COVERAGE_LEDGER);
|
|
135
|
+
if (fs.existsSync(p)) {
|
|
136
|
+
try {
|
|
137
|
+
ledger = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
138
|
+
} catch {
|
|
139
|
+
/* a corrupt ledger reads as no registry → "not asserted", never a false green */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return auditCoverage(capturedSurfaceKeys(dir), ledger);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Print the completeness verdict; return true if it BLOCKS (a registered surface is missing).
|
|
146
|
+
function printCoverageVerdict(v) {
|
|
147
|
+
if (v.basis === 'complete') {
|
|
148
|
+
console.log(`\n✓ coverage complete — all ${v.registrySize} registered surface(s) captured`);
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
if (v.basis === 'unasserted') {
|
|
152
|
+
console.log(
|
|
153
|
+
'\n⚠ completeness NOT asserted — the spec declared no `expected` registry, so this certifies only the\n' +
|
|
154
|
+
' surfaces that were captured, not that they are all of them. Declare `expected` to certify completeness.',
|
|
155
|
+
);
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
console.log(
|
|
159
|
+
`\n✗ coverage INCOMPLETE — ${v.uncovered.length} registered surface(s) not captured (of ${v.registrySize}):`,
|
|
160
|
+
);
|
|
161
|
+
for (const k of v.uncovered) console.log(` ✗ missing: ${k}`);
|
|
162
|
+
console.log(
|
|
163
|
+
" → capture each (or move it to `exclude` with a reason). A green can't certify what was never captured.",
|
|
164
|
+
);
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
113
168
|
const HELP = `${COMMAND} — certify a CSS refactor by diffing two computed-style map captures
|
|
114
169
|
|
|
115
170
|
usage: ${COMMAND} [baseRef] [options]
|
|
@@ -201,12 +256,15 @@ if (args.length <= 1) {
|
|
|
201
256
|
|
|
202
257
|
let result;
|
|
203
258
|
let inventoryAudit = null;
|
|
259
|
+
let coverageVerdict = null;
|
|
204
260
|
try {
|
|
205
261
|
assertCompatibleMapDirs(dirA, dirB);
|
|
206
262
|
result = diffStyleMapDirs(dirA, dirB);
|
|
207
|
-
// Read inventory here, while the (possibly cached/restored) dirs still
|
|
208
|
-
// the finally below deletes them in cached-map mode.
|
|
263
|
+
// Read inventory + coverage here, while the (possibly cached/restored) dirs still
|
|
264
|
+
// exist — the finally below deletes them in cached-map mode. Coverage is the HEAD
|
|
265
|
+
// bundle's completeness basis.
|
|
209
266
|
inventoryAudit = readInventoryAudit(dirA, dirB);
|
|
267
|
+
coverageVerdict = readCoverageVerdict(dirB);
|
|
210
268
|
} catch (e) {
|
|
211
269
|
console.error(e.message);
|
|
212
270
|
process.exit(2);
|
|
@@ -243,8 +301,10 @@ for (const sd of surfaces) {
|
|
|
243
301
|
}
|
|
244
302
|
|
|
245
303
|
const invRemovals = printInventoryAudit(inventoryAudit);
|
|
304
|
+
const coverageFails = printCoverageVerdict(coverageVerdict);
|
|
246
305
|
|
|
247
|
-
if (jsonOut)
|
|
306
|
+
if (jsonOut)
|
|
307
|
+
fs.writeFileSync(jsonOut, JSON.stringify({ counts, surfaces, compared, coverage: coverageVerdict }, null, 2));
|
|
248
308
|
|
|
249
309
|
const total = counts.dom + counts.style + counts.state;
|
|
250
310
|
const newSurfaces = surfaces.filter((s) => s.missing).length;
|
|
@@ -252,13 +312,15 @@ const newSurfaces = surfaces.filter((s) => s.missing).length;
|
|
|
252
312
|
const surfaceCount = surfaces.length;
|
|
253
313
|
const newNote = newSurfaces ? ` (+${newSurfaces} new surface(s) with no baseline)` : '';
|
|
254
314
|
const invNote = invRemovals ? ` + ${invRemovals} unacknowledged inventory removal(s)` : '';
|
|
315
|
+
const covNote = coverageFails ? ` + ${coverageVerdict.uncovered.length} uncaptured registered surface(s)` : '';
|
|
316
|
+
const clean = total === 0 && invRemovals === 0 && !coverageFails;
|
|
255
317
|
console.log(
|
|
256
|
-
|
|
318
|
+
clean
|
|
257
319
|
? newSurfaces === 0
|
|
258
320
|
? `\n✓ 0 changed surfaces across ${compared} captured surface(s): every computed style, pseudo-element, and hover/focus/active state matches`
|
|
259
321
|
: `\nℹ ${newSurfaces} new surface(s) captured with no baseline to compare — review before baselining`
|
|
260
|
-
: `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}${invNote}`,
|
|
322
|
+
: `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}${invNote}${covNote}`,
|
|
261
323
|
);
|
|
262
|
-
// 0 = identical, 1 = reviewable differences (incl. unacknowledged inventory
|
|
263
|
-
//
|
|
264
|
-
process.exit(total > 0 || invRemovals > 0 ? 1 : newSurfaces > 0 ? 3 : 0);
|
|
324
|
+
// 0 = identical, 1 = reviewable differences (incl. unacknowledged inventory removals or
|
|
325
|
+
// an incomplete coverage registry), 3 = only new surfaces (no baseline). 2 = usage/capture error.
|
|
326
|
+
process.exit(total > 0 || invRemovals > 0 || coverageFails ? 1 : newSurfaces > 0 ? 3 : 0);
|
package/bin/styleproof-init.mjs
CHANGED
|
@@ -153,64 +153,37 @@ defineStyleMapCapture({
|
|
|
153
153
|
exclude: Object.fromEntries(
|
|
154
154
|
ROUTES.filter((r) => r.dynamic).map((r) => [r.key, \`dynamic route (\${r.path}) — add a surface with a concrete param\`]),
|
|
155
155
|
),
|
|
156
|
+
inventory: true, // also fail the diff when a nav item / route the UI used to offer disappears
|
|
156
157
|
dir: process.env.STYLEMAP_DIR,
|
|
157
158
|
});
|
|
158
159
|
`;
|
|
159
160
|
|
|
160
|
-
// Non-Next project:
|
|
161
|
-
//
|
|
161
|
+
// Non-Next project: crawl every surface the nav links to, so ANY app captures its
|
|
162
|
+
// whole reachable surface out of the box with nothing to hand-list. The crawl reads
|
|
163
|
+
// the rendered nav; the surface set can't drift from it.
|
|
162
164
|
const GENERIC_SPEC = `import type { Page } from '@playwright/test';
|
|
163
|
-
import {
|
|
165
|
+
import { defineCrawlCapture } from 'styleproof';
|
|
164
166
|
|
|
165
167
|
${HEADER}
|
|
166
168
|
|
|
167
169
|
${SETTLE}
|
|
168
170
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
// Non-live UI states belong here as variants, so the base branch's
|
|
181
|
-
// dialog-open state compares to the head branch's dialog-open state.
|
|
182
|
-
// variants: [
|
|
183
|
-
// {
|
|
184
|
-
// key: 'dialog-open',
|
|
185
|
-
// go: async (page) => {
|
|
186
|
-
// await page.getByRole('button', { name: /open settings/i }).click();
|
|
187
|
-
// await page.getByRole('dialog').waitFor();
|
|
188
|
-
// },
|
|
189
|
-
// },
|
|
190
|
-
// {
|
|
191
|
-
// key: 'popover-open',
|
|
192
|
-
// go: async (page) => {
|
|
193
|
-
// await page.getByRole('button', { name: /more/i }).click();
|
|
194
|
-
// await page.locator('[popover], [role="menu"]').first().waitFor();
|
|
195
|
-
// },
|
|
196
|
-
// },
|
|
197
|
-
// ],
|
|
198
|
-
},
|
|
199
|
-
// Add more surfaces for distinct routes/views; add menus, dialogs, popovers,
|
|
200
|
-
// selected tabs, and form errors as variants of the route/view that owns them.
|
|
201
|
-
];
|
|
202
|
-
|
|
203
|
-
defineStyleMapCapture({
|
|
204
|
-
surfaces: SURFACES,
|
|
205
|
-
// Coverage guard (recommended): declare every route your app knows about so a
|
|
206
|
-
// newly added page can't ship without a surface. Wire \`expected\` to your route
|
|
207
|
-
// registry — a routes/views list, your router config, or a glob of your pages —
|
|
208
|
-
// and StyleProof fails the suite (no STYLEMAP_DIR needed) when a route has no
|
|
209
|
-
// surface and isn't excluded. (A Next.js app gets this auto-wired; see
|
|
210
|
-
// \`discoverNextRoutes\` in the README.)
|
|
211
|
-
// expected: ROUTES.map((r) => r.id),
|
|
212
|
-
// exclude: { checkout: 'auth-gated — fixture pending' },
|
|
171
|
+
// Zero-config capture: crawl every surface your nav links to from '/'. The surface set
|
|
172
|
+
// is DISCOVERED from the rendered nav, so it can't drift from it — no hand-listed
|
|
173
|
+
// \`surfaces\` array to maintain, and a page you add to the nav is captured automatically.
|
|
174
|
+
// The root (/) is always captured, plus every same-origin <a href> it links to.
|
|
175
|
+
defineCrawlCapture({
|
|
176
|
+
from: '/',
|
|
177
|
+
settle, // trigger scroll-reveal per surface (StyleProof handles fonts/animation/network itself)
|
|
178
|
+
// No \`widths\` → StyleProof detects each surface's @media breakpoints and sweeps one
|
|
179
|
+
// viewport per band. Pass an array (e.g. [1440, 768, 390]) to pin them.
|
|
180
|
+
inventory: true, // also fail the diff when a nav item / route the UI used to offer disappears
|
|
181
|
+
ignore: [], // e.g. ['.live-feed', '.ad-slot'] for nondeterministic regions
|
|
213
182
|
dir: process.env.STYLEMAP_DIR,
|
|
183
|
+
// A single-route SPA whose views are ?tab= / client-routed? Keep only those:
|
|
184
|
+
// match: /\\?tab=/,
|
|
185
|
+
// Certify menus, dialogs, tabs, and form-error states on every surface as variants:
|
|
186
|
+
// variants: [{ key: 'menu-open', go: async (page) => { await page.getByRole('button', { name: /menu/i }).click(); } }],
|
|
214
187
|
});
|
|
215
188
|
`;
|
|
216
189
|
|
|
@@ -472,7 +445,8 @@ if (spec.wrote) {
|
|
|
472
445
|
(dynamic ? ` (${dynamic} dynamic route(s) excluded pending a concrete param)` : ''),
|
|
473
446
|
);
|
|
474
447
|
} else {
|
|
475
|
-
console.log(' no Next.js routes detected — wrote a
|
|
448
|
+
console.log(' no Next.js routes detected — wrote a crawl-by-default spec that captures every');
|
|
449
|
+
console.log(' surface your nav links to from / (nothing to hand-list; the inventory guard is on)');
|
|
476
450
|
}
|
|
477
451
|
wroteSomething = true;
|
|
478
452
|
} else {
|
|
@@ -508,15 +482,14 @@ if (ci.wrote) {
|
|
|
508
482
|
console.log(`${CI_PATH} already exists — left untouched`);
|
|
509
483
|
}
|
|
510
484
|
|
|
511
|
-
console.log('\nHow the gate works —
|
|
512
|
-
console.log(' 1. Commit
|
|
485
|
+
console.log('\nHow the gate works — it runs on your first PR with no extra steps:');
|
|
486
|
+
console.log(' 1. Commit and open a PR. CI captures the base and head surfaces in one pinned');
|
|
487
|
+
console.log(' environment and posts the StyleProof report — no local step required.');
|
|
488
|
+
console.log(' 2. (Optional, faster) Pre-cache this commit so CI skips recapturing the base:');
|
|
513
489
|
console.log(' npx styleproof-map');
|
|
514
|
-
console.log(' It captures a production-build map into .styleproof/ and uploads the bundle');
|
|
515
|
-
console.log('
|
|
516
|
-
console.log('
|
|
517
|
-
console.log(' without a browser when both maps are present and compatible.');
|
|
518
|
-
console.log(' 3. If a bundle is missing or incompatible, CI recaptures both sides in the same');
|
|
519
|
-
console.log(' environment before generating the report. Correctness beats a stale cache.');
|
|
490
|
+
console.log(' It captures a production-build map into .styleproof/ and uploads the bundle to');
|
|
491
|
+
console.log(' the styleproof-maps branch when the git remote is available; CI then restores');
|
|
492
|
+
console.log(' it and generates the report without a browser.');
|
|
520
493
|
|
|
521
494
|
if (!wroteSomething) console.log('\nnothing to write — project already scaffolded.');
|
|
522
495
|
process.exit(0);
|
package/dist/coverage.d.ts
CHANGED
|
@@ -34,3 +34,26 @@ export type CoverageGaps = {
|
|
|
34
34
|
* dir), and it's exported so a consumer can assert coverage however it likes.
|
|
35
35
|
*/
|
|
36
36
|
export declare function coverageGaps(capturedKeys: Iterable<string>, expected: Iterable<string>, exclude?: Record<string, string>): CoverageGaps;
|
|
37
|
+
/** Bundled next to the maps, so the completeness basis travels with the capture. */
|
|
38
|
+
export declare const COVERAGE_LEDGER = "styleproof-coverage.json";
|
|
39
|
+
export type CoverageLedger = {
|
|
40
|
+
version: 1;
|
|
41
|
+
/** The declared surface registry, or null when the spec asserted none. */
|
|
42
|
+
expected: string[] | null;
|
|
43
|
+
/** Reviewed opt-outs (`key → reason`). */
|
|
44
|
+
exclude: Record<string, string>;
|
|
45
|
+
};
|
|
46
|
+
export type CoverageVerdict = {
|
|
47
|
+
/** `complete` — every registered surface captured; `incomplete` — a registered
|
|
48
|
+
* surface is missing (gates); `unasserted` — no registry, so a green can only
|
|
49
|
+
* certify the captured surfaces, not that they are all of them. */
|
|
50
|
+
basis: 'complete' | 'incomplete' | 'unasserted';
|
|
51
|
+
/** Size of the declared registry, or null when unasserted. */
|
|
52
|
+
registrySize: number | null;
|
|
53
|
+
/** Registered surfaces neither captured nor excluded — the coverage hole. */
|
|
54
|
+
uncovered: string[];
|
|
55
|
+
/** `exclude` entries no longer in `expected` — a rotted opt-out. */
|
|
56
|
+
staleExclusions: string[];
|
|
57
|
+
};
|
|
58
|
+
/** The gate's completeness call: audit what was actually captured against the ledger. */
|
|
59
|
+
export declare function auditCoverage(capturedKeys: Iterable<string>, ledger: CoverageLedger | null): CoverageVerdict;
|
package/dist/coverage.js
CHANGED
|
@@ -34,3 +34,26 @@ export function coverageGaps(capturedKeys, expected, exclude = {}) {
|
|
|
34
34
|
const staleExclusions = Object.keys(exclude).filter((k) => !expectedSet.has(k));
|
|
35
35
|
return { uncovered, staleExclusions };
|
|
36
36
|
}
|
|
37
|
+
// ── coverage provenance (the gate-level completeness assertion) ──────────────────
|
|
38
|
+
// The guard above runs in the app's SUITE. That fails a build when the spec forgets a
|
|
39
|
+
// route, but the GATE (styleproof-diff, reading captured maps) never learns whether
|
|
40
|
+
// coverage was asserted at all — so a green silently implies a completeness it can't
|
|
41
|
+
// back up. The capture writes this ledger into the bundle; the gate reads it and
|
|
42
|
+
// certifies "clean" only against a STATED basis, enforcing the registry against the
|
|
43
|
+
// maps actually captured (a declared surface whose capture FAILED is caught here, where
|
|
44
|
+
// the suite guard — which checks the declared list — cannot see it).
|
|
45
|
+
/** Bundled next to the maps, so the completeness basis travels with the capture. */
|
|
46
|
+
export const COVERAGE_LEDGER = 'styleproof-coverage.json';
|
|
47
|
+
/** The gate's completeness call: audit what was actually captured against the ledger. */
|
|
48
|
+
export function auditCoverage(capturedKeys, ledger) {
|
|
49
|
+
if (!ledger || ledger.expected == null) {
|
|
50
|
+
return { basis: 'unasserted', registrySize: null, uncovered: [], staleExclusions: [] };
|
|
51
|
+
}
|
|
52
|
+
const { uncovered, staleExclusions } = coverageGaps(capturedKeys, ledger.expected, ledger.exclude);
|
|
53
|
+
return {
|
|
54
|
+
basis: uncovered.length ? 'incomplete' : 'complete',
|
|
55
|
+
registrySize: ledger.expected.length,
|
|
56
|
+
uncovered,
|
|
57
|
+
staleExclusions,
|
|
58
|
+
};
|
|
59
|
+
}
|
package/dist/crawl.d.ts
CHANGED
|
@@ -37,6 +37,10 @@ export type SelectLinksOptions = {
|
|
|
37
37
|
match?: LinkMatch;
|
|
38
38
|
/** Derive the surface key from a link URL. Default: {@link defaultLinkKey}. */
|
|
39
39
|
key?: (url: URL) => string;
|
|
40
|
+
/** Also capture the crawled page itself as the first surface, so `from` is always
|
|
41
|
+
* covered — even if the nav doesn't link back to it, or it's a single-page app with
|
|
42
|
+
* no links at all. Default false. Used for an unfiltered "capture everything" crawl. */
|
|
43
|
+
includeSelf?: boolean;
|
|
40
44
|
};
|
|
41
45
|
/**
|
|
42
46
|
* Filename-safe, readable key from a link URL. Joins the path segments and the
|
package/dist/crawl.js
CHANGED
|
@@ -83,6 +83,11 @@ export function selectCrawlLinks(hrefs, opts) {
|
|
|
83
83
|
const keyFor = opts.key ?? defaultLinkKey;
|
|
84
84
|
const seen = new Set();
|
|
85
85
|
const out = [];
|
|
86
|
+
if (opts.includeSelf) {
|
|
87
|
+
const selfUrl = base.pathname + base.search;
|
|
88
|
+
seen.add(selfUrl);
|
|
89
|
+
out.push({ key: keyFor(base), url: selfUrl });
|
|
90
|
+
}
|
|
86
91
|
for (const href of hrefs) {
|
|
87
92
|
const link = href ? toLink(href, base, keyFor, opts.match) : null;
|
|
88
93
|
if (!link || seen.has(link.url))
|
package/dist/diff.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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
5
|
function diffProps(propsA, propsB, fallbackA, fallbackB, unsetA, unsetB) {
|
|
5
6
|
const changed = [];
|
|
6
7
|
for (const prop of new Set([...Object.keys(propsA), ...Object.keys(propsB)])) {
|
|
@@ -173,7 +174,7 @@ function tallyCounts(findings, counts) {
|
|
|
173
174
|
function indexDir(dir) {
|
|
174
175
|
return Object.fromEntries(fs
|
|
175
176
|
.readdirSync(dir)
|
|
176
|
-
.filter(
|
|
177
|
+
.filter(isMapFile)
|
|
177
178
|
.map((f) => [f.replace(/\.json(\.gz)?$/, ''), path.join(dir, f)]));
|
|
178
179
|
}
|
|
179
180
|
/** Diff every same-named capture between two directories. `volatile` is the
|
package/dist/map-store.d.ts
CHANGED
|
@@ -3,6 +3,12 @@ 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
|
+
/** Bundle files that sit alongside the maps but are NOT surfaces (manifest, coverage
|
|
7
|
+
* ledger, and any future sidecar). Every place that enumerates surface maps must skip
|
|
8
|
+
* these, or a sidecar reads as a phantom "new surface". */
|
|
9
|
+
export declare const RESERVED_BUNDLE_FILES: ReadonlySet<string>;
|
|
10
|
+
/** True for a captured surface map (`<key>@<width>.json[.gz]`), false for metadata. */
|
|
11
|
+
export declare function isMapFile(name: string): boolean;
|
|
6
12
|
export declare class MapStoreError extends Error {
|
|
7
13
|
}
|
|
8
14
|
export interface MapManifest {
|
package/dist/map-store.js
CHANGED
|
@@ -5,12 +5,21 @@ import { createRequire } from 'node:module';
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { inferBaseRef } from './gitref.js';
|
|
8
|
+
import { COVERAGE_LEDGER } from './coverage.js';
|
|
8
9
|
export const DEFAULT_MAP_DIR = '.styleproof/maps';
|
|
9
10
|
export const DEFAULT_MAP_LABEL = 'current';
|
|
10
11
|
export const DEFAULT_MAP_STORE_BRANCH = 'styleproof-maps';
|
|
11
12
|
export const DEFAULT_REMOTE = 'origin';
|
|
12
13
|
export const MAP_MANIFEST = 'styleproof-manifest.json';
|
|
13
14
|
const GENERATED_DIRTY_ALLOWLIST = new Set(['next-env.d.ts']);
|
|
15
|
+
/** Bundle files that sit alongside the maps but are NOT surfaces (manifest, coverage
|
|
16
|
+
* ledger, and any future sidecar). Every place that enumerates surface maps must skip
|
|
17
|
+
* these, or a sidecar reads as a phantom "new surface". */
|
|
18
|
+
export const RESERVED_BUNDLE_FILES = new Set([MAP_MANIFEST, COVERAGE_LEDGER]);
|
|
19
|
+
/** True for a captured surface map (`<key>@<width>.json[.gz]`), false for metadata. */
|
|
20
|
+
export function isMapFile(name) {
|
|
21
|
+
return !RESERVED_BUNDLE_FILES.has(name) && /\.json(\.gz)?$/.test(name);
|
|
22
|
+
}
|
|
14
23
|
export class MapStoreError extends Error {
|
|
15
24
|
}
|
|
16
25
|
function runGit(cwd, args, maxBuffer = 1 << 28) {
|
package/dist/report.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { PNG } from 'pngjs';
|
|
4
4
|
import { loadStyleMap } from './capture.js';
|
|
5
|
+
import { isMapFile } from './map-store.js';
|
|
5
6
|
import { diffStyleMapDirs, diffContentDirs, } from './diff.js';
|
|
6
7
|
import { describeChange, tokenIndex, toHex, trackCount } from './describe.js';
|
|
7
8
|
// Re-export the plain-English summariser so consumers (and tests) reach it
|
|
@@ -443,9 +444,7 @@ function liveCandidateLabel(candidate) {
|
|
|
443
444
|
return `${label} (${candidate.reason})`;
|
|
444
445
|
}
|
|
445
446
|
function captureFiles(dir) {
|
|
446
|
-
return fs.existsSync(dir)
|
|
447
|
-
? fs.readdirSync(dir).filter((f) => f !== 'styleproof-manifest.json' && /\.json(\.gz)?$/.test(f))
|
|
448
|
-
: [];
|
|
447
|
+
return fs.existsSync(dir) ? fs.readdirSync(dir).filter(isMapFile) : [];
|
|
449
448
|
}
|
|
450
449
|
function collectLiveCandidateLabels(beforeDir, afterDir) {
|
|
451
450
|
const seen = new Set();
|
package/dist/runner.d.ts
CHANGED
|
@@ -168,6 +168,15 @@ export type DefineOptions = {
|
|
|
168
168
|
* their exact capture set unless this is enabled.
|
|
169
169
|
*/
|
|
170
170
|
popups?: boolean | PopupCaptureOptions;
|
|
171
|
+
/**
|
|
172
|
+
* Opt-in inventory guard (default OFF). Harvest each surface's navigable
|
|
173
|
+
* affordances — route links, `role=tab`/`menuitem`, button-only nav — into
|
|
174
|
+
* `StyleMap.inventory`, so `styleproof-diff` fails when a nav item / route the UI
|
|
175
|
+
* used to offer disappears (acknowledge intentional removals in
|
|
176
|
+
* `styleproof.inventory.json`). Additive; ignored by the certification style diff.
|
|
177
|
+
* See `docs/inventory-guard.md`.
|
|
178
|
+
*/
|
|
179
|
+
inventory?: boolean;
|
|
171
180
|
};
|
|
172
181
|
export declare function selfCheckErrorMessage(surfaceKey: string, drift: Finding[], volatile?: string[], liveCandidates?: LiveRegionCandidate[]): string;
|
|
173
182
|
type ResolvedPopupCaptureOptions = Required<PopupCaptureOptions>;
|
|
@@ -213,19 +222,6 @@ export declare function resolveBaseDir(baseDir: string | undefined, env?: string
|
|
|
213
222
|
export declare function resolveScreenshots(screenshots: boolean | undefined, env?: string | undefined): boolean;
|
|
214
223
|
/** The capture settings every capturer shares (everything bar the surface set). */
|
|
215
224
|
type CaptureConfig = Omit<DefineOptions, 'surfaces' | 'expected' | 'exclude'>;
|
|
216
|
-
/**
|
|
217
|
-
* Generate one Playwright test per surface × width that captures the style
|
|
218
|
-
* map to `<baseDir>/<dir>/<key>@<width>.json.gz`. Captures are made
|
|
219
|
-
* deterministic with no per-repo fixtures: the baseline run records each
|
|
220
|
-
* surface's data responses to a HAR, and the comparison run replays them (set
|
|
221
|
-
* STYLEPROOF_REPLAY_FROM=<baseline dir> on the comparison capture), while the
|
|
222
|
-
* clock is frozen so time-derived styling is stable.
|
|
223
|
-
*
|
|
224
|
-
* ```ts
|
|
225
|
-
* // styleproof.spec.ts
|
|
226
|
-
* defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
|
|
227
|
-
* ```
|
|
228
|
-
*/
|
|
229
225
|
export declare function defineStyleMapCapture(options: DefineOptions): void;
|
|
230
226
|
/** Options for {@link defineCrawlCapture}: where to crawl, how to filter/key the
|
|
231
227
|
* links, and the viewport sweep — plus the shared capture settings. */
|
|
@@ -238,10 +234,16 @@ export type CrawlOptions = CaptureConfig & {
|
|
|
238
234
|
match?: LinkMatch;
|
|
239
235
|
/** Derive a surface key from a link URL. Default: path+query slug (`/?tab=x` → `x`). */
|
|
240
236
|
key?: (url: URL) => string;
|
|
241
|
-
/** Viewport widths swept for every discovered surface
|
|
242
|
-
|
|
237
|
+
/** Viewport widths swept for every discovered surface. Omit to auto-detect each
|
|
238
|
+
* surface's @media breakpoints (one viewport per band) — the same zero-config
|
|
239
|
+
* behaviour as an explicit surface with no `widths`. */
|
|
240
|
+
widths?: number[];
|
|
243
241
|
/** Viewport height per width (default 800). */
|
|
244
242
|
height?: number | ((width: number) => number);
|
|
243
|
+
/** Run after navigating to each discovered link, before capture — e.g. to trigger
|
|
244
|
+
* scroll-reveal content. The built-in font/animation/network settle always runs;
|
|
245
|
+
* this is the app-specific hook, the crawl's parity with a hand-listed surface's `go`. */
|
|
246
|
+
settle?: (page: Page) => Promise<void>;
|
|
245
247
|
/** Selectors skipped on every surface (live regions, third-party embeds). */
|
|
246
248
|
ignore?: string[];
|
|
247
249
|
/** Deterministic variants captured for every discovered link surface. */
|
package/dist/runner.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { captureStyleMap, saveStyleMap, trackInflightRequests, } from './capture.js';
|
|
5
5
|
import { diffStyleMaps } from './diff.js';
|
|
6
|
-
import { coverageGaps } from './coverage.js';
|
|
6
|
+
import { coverageGaps, COVERAGE_LEDGER } from './coverage.js';
|
|
7
7
|
import { detectViewportWidths } from './breakpoints.js';
|
|
8
8
|
import { selectCrawlLinks } from './crawl.js';
|
|
9
9
|
/** One-line description of the first drift finding, for the self-check error. */
|
|
@@ -371,6 +371,7 @@ async function captureSurface(page, surface, width, s) {
|
|
|
371
371
|
ignore: surface.ignore ?? [],
|
|
372
372
|
captureText: s.captureText,
|
|
373
373
|
captureComponent: s.captureComponent,
|
|
374
|
+
inventory: s.inventory,
|
|
374
375
|
pendingRequests: requests.pending,
|
|
375
376
|
metadata: surface.metadata,
|
|
376
377
|
});
|
|
@@ -435,6 +436,7 @@ function resolveSettings(c) {
|
|
|
435
436
|
captureText: c.captureText ?? false,
|
|
436
437
|
captureComponent: c.captureComponent ?? false,
|
|
437
438
|
popups: resolvePopupCaptureOptions(c.popups),
|
|
439
|
+
inventory: c.inventory ?? false,
|
|
438
440
|
};
|
|
439
441
|
}
|
|
440
442
|
/**
|
|
@@ -450,6 +452,20 @@ function resolveSettings(c) {
|
|
|
450
452
|
* defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
|
|
451
453
|
* ```
|
|
452
454
|
*/
|
|
455
|
+
/**
|
|
456
|
+
* Emit a test that records the coverage ledger into the capture bundle, so the GATE
|
|
457
|
+
* (styleproof-diff) can state its completeness basis — not just the app's own suite.
|
|
458
|
+
* `expected: null` records that the spec declared no registry, so a green can only
|
|
459
|
+
* certify the captured surfaces. Runs on a capture run (dir set) only.
|
|
460
|
+
*/
|
|
461
|
+
function writeCoverageLedgerTest(baseDir, dir, expected, exclude) {
|
|
462
|
+
test('styleproof coverage ledger', () => {
|
|
463
|
+
const outDir = path.join(baseDir, dir);
|
|
464
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
465
|
+
const ledger = { version: 1, expected, exclude };
|
|
466
|
+
fs.writeFileSync(path.join(outDir, COVERAGE_LEDGER), JSON.stringify(ledger, null, 2));
|
|
467
|
+
});
|
|
468
|
+
}
|
|
453
469
|
export function defineStyleMapCapture(options) {
|
|
454
470
|
const { surfaces, expected, exclude = {}, dir } = options;
|
|
455
471
|
const settings = resolveSettings(options);
|
|
@@ -473,6 +489,8 @@ export function defineStyleMapCapture(options) {
|
|
|
473
489
|
}
|
|
474
490
|
test.describe('styleproof capture', () => {
|
|
475
491
|
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
492
|
+
if (dir)
|
|
493
|
+
writeCoverageLedgerTest(settings.baseDir, dir, expected ?? null, exclude);
|
|
476
494
|
for (const surface of captureSurfaces) {
|
|
477
495
|
if (surface.widths && surface.widths.length > 0) {
|
|
478
496
|
// Explicit widths: one parallelizable test per surface × width.
|
|
@@ -517,17 +535,33 @@ export function defineStyleMapCapture(options) {
|
|
|
517
535
|
* ```
|
|
518
536
|
*/
|
|
519
537
|
export function defineCrawlCapture(options) {
|
|
520
|
-
const { from, match, key, widths, height, ignore, variants, liveStates, popups, linkTimeout = 15_000, dir } = options;
|
|
538
|
+
const { from, match, key, widths, height, ignore, variants, liveStates, popups, linkTimeout = 15_000, dir, settle, } = options;
|
|
521
539
|
const settings = resolveSettings(options);
|
|
522
|
-
|
|
540
|
+
// Title contains "styleproof capture" so the same `--grep 'styleproof capture'`
|
|
541
|
+
// that styleproof-map uses to select capture tests picks up crawl specs too.
|
|
542
|
+
test.describe('styleproof capture (crawl)', () => {
|
|
523
543
|
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
544
|
+
// A crawl DISCOVERS its surfaces from the nav — it has no registry to check against,
|
|
545
|
+
// so it records `expected: null` (completeness honestly "not asserted": the crawl
|
|
546
|
+
// captures what the nav links to, and can't prove that's every route in the app).
|
|
547
|
+
if (dir)
|
|
548
|
+
writeCoverageLedgerTest(settings.baseDir, dir, null, {});
|
|
524
549
|
test('discover surfaces by crawling links, then capture each', async ({ page }) => {
|
|
525
550
|
// 1. Load the root and wait for its nav links to hydrate — an SPA renders them
|
|
526
551
|
// client-side, so they aren't in the initial HTML.
|
|
527
552
|
await page.goto(from, { waitUntil: 'load' });
|
|
528
|
-
|
|
553
|
+
// Wait for the nav's links to hydrate (an SPA renders them client-side). A link-less
|
|
554
|
+
// page is fine for an unfiltered crawl — it still captures `from` itself (includeSelf);
|
|
555
|
+
// a `match`-filtered crawl genuinely needs links, so let its timeout surface.
|
|
556
|
+
await page.waitForSelector('a[href]', { timeout: linkTimeout }).catch((e) => {
|
|
557
|
+
if (match !== undefined)
|
|
558
|
+
throw e;
|
|
559
|
+
});
|
|
529
560
|
const hrefs = await page.$$eval('a[href]', (els) => els.map((e) => e.getAttribute('href')));
|
|
530
|
-
|
|
561
|
+
// Unfiltered crawl → also capture `from` itself, so the root is always covered and
|
|
562
|
+
// a single-page (or no-nav-self-link) app still yields a surface. A `match`-filtered
|
|
563
|
+
// crawl captures only the links the caller asked for.
|
|
564
|
+
const links = selectCrawlLinks(hrefs, { base: page.url(), match, key, includeSelf: match === undefined });
|
|
531
565
|
if (links.length === 0) {
|
|
532
566
|
throw new Error(`styleproof crawl: no links matched at ${from}. The nav must render same-origin ` +
|
|
533
567
|
`<a href> links (a button-only nav exposes nothing to crawl), and \`match\` must keep them.`);
|
|
@@ -536,6 +570,8 @@ export function defineCrawlCapture(options) {
|
|
|
536
570
|
key: link.key,
|
|
537
571
|
go: async (p) => {
|
|
538
572
|
await p.goto(link.url, { waitUntil: 'load' });
|
|
573
|
+
if (settle)
|
|
574
|
+
await settle(p);
|
|
539
575
|
},
|
|
540
576
|
widths,
|
|
541
577
|
ignore,
|
|
@@ -546,12 +582,27 @@ export function defineCrawlCapture(options) {
|
|
|
546
582
|
}));
|
|
547
583
|
// Budget the whole sweep up front: one test captures every surface, and
|
|
548
584
|
// captureSurface no longer sets its own timeout, so size it to the work found.
|
|
549
|
-
|
|
585
|
+
// With auto-width the band count isn't known until each surface renders, so
|
|
586
|
+
// assume up to 4 bands per surface.
|
|
587
|
+
test.setTimeout(Math.max(180_000, captureSurfaces.reduce((sum, surface) => sum + (surface.widths?.length ?? 4), 0) * 60_000));
|
|
550
588
|
// 2. Capture each discovered surface. Aggregate failures so one bad surface
|
|
551
589
|
// reports without skipping the rest — they're an independent set, not a chain.
|
|
552
590
|
const failures = [];
|
|
553
591
|
for (const surface of captureSurfaces) {
|
|
554
|
-
|
|
592
|
+
// Auto-width parity with explicit surfaces: no widths given → navigate once and
|
|
593
|
+
// detect this surface's @media bands, then sweep one viewport per band.
|
|
594
|
+
let sweep = surface.widths;
|
|
595
|
+
if (!sweep || sweep.length === 0) {
|
|
596
|
+
try {
|
|
597
|
+
await surface.go(page);
|
|
598
|
+
sweep = await detectViewportWidths(page);
|
|
599
|
+
}
|
|
600
|
+
catch (e) {
|
|
601
|
+
failures.push(`${surface.key} @ auto: ${e.message}`);
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
for (const width of sweep) {
|
|
555
606
|
try {
|
|
556
607
|
await captureSurface(page, surface, width, settings);
|
|
557
608
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.9.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",
|