styleproof 3.6.0 → 3.8.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 +63 -0
- package/bin/styleproof-diff.mjs +80 -5
- package/bin/styleproof-init.mjs +29 -56
- package/dist/capture.d.ts +14 -6
- package/dist/capture.js +11 -0
- package/dist/crawl.d.ts +4 -0
- package/dist/crawl.js +5 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/inventory.d.ts +70 -0
- package/dist/inventory.js +135 -0
- package/dist/runner.d.ts +17 -2
- package/dist/runner.js +36 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,69 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.8.0] - 2026-07-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Zero-config out of the box** — `styleproof-init` now scaffolds a crawl-by-default
|
|
15
|
+
spec for any non-Next.js app: `defineCrawlCapture({ from: '/', settle, inventory: true })`
|
|
16
|
+
captures every surface the nav links to (the root plus each same-origin `<a href>`)
|
|
17
|
+
with **nothing to hand-list** — the surface set is discovered from the rendered nav and
|
|
18
|
+
can't drift from it. Next.js keeps filesystem route discovery; both variants now enable
|
|
19
|
+
the inventory guard by default.
|
|
20
|
+
- **`defineCrawlCapture` auto-width** — omit `widths` and StyleProof detects each
|
|
21
|
+
discovered surface's `@media` breakpoints and sweeps one viewport per band, the same
|
|
22
|
+
zero-config behaviour explicit surfaces already had.
|
|
23
|
+
- **`defineCrawlCapture` `settle` hook** — run an app-specific step (e.g. trigger
|
|
24
|
+
scroll-reveal) after navigating to each crawled surface, for parity with a hand-listed
|
|
25
|
+
surface's `go`.
|
|
26
|
+
- **`inventory` spec option** — `defineStyleMapCapture` / `defineCrawlCapture` now forward
|
|
27
|
+
`inventory: true` to the capture, so turning the inventory guard on (a removed nav item
|
|
28
|
+
fails `styleproof-diff`) is a one-line opt-in from a spec, no manual `captureStyleMap`.
|
|
29
|
+
- The crawl now always covers its `from` root on an unfiltered crawl, so a home page not
|
|
30
|
+
linked in its own nav — or a single-page app with no links — is still captured.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- `styleproof-init` guidance now leads with "it runs on your first PR with no extra
|
|
35
|
+
steps" (CI captures both sides on a cache miss); the local `npx styleproof-map`
|
|
36
|
+
pre-cache is framed as an optional speedup, not a required first step.
|
|
37
|
+
|
|
38
|
+
### Tests / docs
|
|
39
|
+
|
|
40
|
+
- **Dogfood: the "100% surfaced" contract** — `test/pr-surfacing.e2e.spec.ts` runs the
|
|
41
|
+
real capture → diff → report flow for every change class (resting style,
|
|
42
|
+
`:hover/:focus/:active` drop, `::before/::after`, DOM add/remove/retag, a removed nav
|
|
43
|
+
item via the inventory guard, a new surface, and a clean no-op) and asserts each is
|
|
44
|
+
surfaced — the last two levels through the actual `styleproof-diff` / `styleproof-report`
|
|
45
|
+
CLIs. Closes the four classes that previously had no end-to-end proof (`:active` drop,
|
|
46
|
+
DOM removed, retag, pseudo-element change).
|
|
47
|
+
- **Dogfood: zero-config flow** — `test/cli-flow.e2e.spec.ts` runs `styleproof-init` on a
|
|
48
|
+
real multi-page app and proves the generated crawl spec captures every page (root +
|
|
49
|
+
pricing + about), multi-width, with the inventory harvested — no spec editing.
|
|
50
|
+
- **`docs/what-it-catches.md`** — states what StyleProof catches and its honest boundary
|
|
51
|
+
(surfaces it never captured), so a green check is earned, not assumed.
|
|
52
|
+
|
|
53
|
+
## [3.7.0] - 2026-07-04
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
- **Inventory guard** — StyleProof can now assert the navigable UI doesn't silently
|
|
58
|
+
shrink. Opt in with `captureStyleMap(page, { inventory: true })` and each surface's
|
|
59
|
+
navigable affordances — internal route links, `role=tab`, `role=menuitem`,
|
|
60
|
+
button-only nav — are harvested (keyed stably) into `StyleMap.inventory`.
|
|
61
|
+
`styleproof-diff` then unions the reachable set across both sides and **exits 1 on
|
|
62
|
+
any affordance present on base but absent on head** — a feature that stopped being
|
|
63
|
+
reachable — unless it's acknowledged in `styleproof.inventory.json` (`{"<key>":
|
|
64
|
+
"<why>"}`, path overridable via `STYLEPROOF_INVENTORY`; a stale acknowledgement is
|
|
65
|
+
flagged so the ledger can't rot). Closes the certification diff's blind spot for the
|
|
66
|
+
information-architecture / replacement class: a redesign staged as a new surface, or
|
|
67
|
+
a nav item / route that disappears, which a same-surface computed-style diff catches
|
|
68
|
+
only incidentally. **Off by default (no map carries inventory ⇒ the CLI is
|
|
69
|
+
byte-for-byte unchanged); the certification diff itself is untouched.** The
|
|
70
|
+
programmatic entry point `auditRunInventory(baseMaps, headMaps, allowRemoved)`
|
|
71
|
+
stays available for custom gates. See `docs/inventory-guard.md`.
|
|
72
|
+
|
|
10
73
|
## [3.6.0] - 2026-07-03
|
|
11
74
|
|
|
12
75
|
### Changed
|
package/bin/styleproof-diff.mjs
CHANGED
|
@@ -39,9 +39,77 @@ import {
|
|
|
39
39
|
showHelpAndExit,
|
|
40
40
|
unknownFlagMessage,
|
|
41
41
|
} from '../dist/cli-errors.js';
|
|
42
|
+
import { loadStyleMap } from '../dist/capture.js';
|
|
43
|
+
import { auditRunInventory } from '../dist/inventory.js';
|
|
42
44
|
|
|
43
45
|
const COMMAND = path.basename(process.argv[1] ?? 'styleproof-diff').replace(/\.mjs$/, '');
|
|
44
46
|
|
|
47
|
+
// ── inventory guard (opt-in) ────────────────────────────────────────────────────
|
|
48
|
+
// Surfaces the navigable-inventory audit through the CLI. When captures carry
|
|
49
|
+
// `inventory` (from captureStyleMap({ inventory: true })), an affordance base
|
|
50
|
+
// offered and head no longer does BLOCKS unless acknowledged. Inert when no map
|
|
51
|
+
// carries inventory, so every existing capture behaves exactly as before.
|
|
52
|
+
|
|
53
|
+
// Mirror of indexDir() in diff.ts — the capture-dir file filter, kept local so the
|
|
54
|
+
// bin needn't reach into diff internals for one small read.
|
|
55
|
+
function dirInventories(dir) {
|
|
56
|
+
return fs
|
|
57
|
+
.readdirSync(dir)
|
|
58
|
+
.filter((f) => f !== 'styleproof-manifest.json' && /\.json(\.gz)?$/.test(f))
|
|
59
|
+
.map((f) => loadStyleMap(path.join(dir, f)))
|
|
60
|
+
.map((m) => (m.inventory ? { inventory: m.inventory } : {}));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// `key -> reason` acknowledged removals. Optional file; absent → none. Malformed
|
|
64
|
+
// JSON fails loud (exit 2) rather than silently un-acknowledging a real removal.
|
|
65
|
+
function loadAllowRemoved() {
|
|
66
|
+
const p = path.resolve(process.env.STYLEPROOF_INVENTORY ?? 'styleproof.inventory.json');
|
|
67
|
+
if (!fs.existsSync(p)) return {};
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
70
|
+
} catch (e) {
|
|
71
|
+
console.error(`${COMMAND}: ${p} is not valid JSON — ${e.message}`);
|
|
72
|
+
process.exit(2);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Read both sides' inventory and audit removals. MUST run before any cached-map
|
|
77
|
+
// cleanup deletes the restored dirs. Returns null when no capture carries inventory.
|
|
78
|
+
function readInventoryAudit(dirA, dirB) {
|
|
79
|
+
const baseInv = dirInventories(dirA);
|
|
80
|
+
const headInv = dirInventories(dirB);
|
|
81
|
+
if (![...baseInv, ...headInv].some((m) => m.inventory?.length)) return null;
|
|
82
|
+
const allowed = loadAllowRemoved();
|
|
83
|
+
return { allowed, ...auditRunInventory(baseInv, headInv, allowed) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Print the Inventory section from a prior audit; return the count of UNACKNOWLEDGED
|
|
87
|
+
// removals (which block). No-op/0 when there was nothing with inventory to audit.
|
|
88
|
+
function printInventoryAudit(audit) {
|
|
89
|
+
if (!audit) return 0;
|
|
90
|
+
const { delta, unexplained, staleAllowances, allowed } = audit;
|
|
91
|
+
if (!delta.added.length && !delta.removed.length && !staleAllowances.length) {
|
|
92
|
+
console.log('\n📐 Inventory: navigable set unchanged across captured surfaces');
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
console.log('\n📐 Inventory (navigable affordances — route links, tabs, menu items, nav buttons):');
|
|
96
|
+
for (const it of delta.removed) {
|
|
97
|
+
const why = allowed[it.key];
|
|
98
|
+
console.log(
|
|
99
|
+
why
|
|
100
|
+
? ` removed: ${it.key} ("${it.label}") — acknowledged: ${why}`
|
|
101
|
+
: ` ✗ REMOVED, unacknowledged: ${it.key} ("${it.label}")`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
for (const it of delta.added) console.log(` + added: ${it.key} ("${it.label}")`);
|
|
105
|
+
for (const k of staleAllowances) console.log(` ⚠ stale allowRemoved (key is not actually removed): ${k}`);
|
|
106
|
+
if (unexplained.length)
|
|
107
|
+
console.log(
|
|
108
|
+
` → ${unexplained.length} unacknowledged removal(s): restore the affordance, or record the decision in styleproof.inventory.json {"<key>":"<why>"}.`,
|
|
109
|
+
);
|
|
110
|
+
return unexplained.length;
|
|
111
|
+
}
|
|
112
|
+
|
|
45
113
|
const HELP = `${COMMAND} — certify a CSS refactor by diffing two computed-style map captures
|
|
46
114
|
|
|
47
115
|
usage: ${COMMAND} [baseRef] [options]
|
|
@@ -132,9 +200,13 @@ if (args.length <= 1) {
|
|
|
132
200
|
}
|
|
133
201
|
|
|
134
202
|
let result;
|
|
203
|
+
let inventoryAudit = null;
|
|
135
204
|
try {
|
|
136
205
|
assertCompatibleMapDirs(dirA, dirB);
|
|
137
206
|
result = diffStyleMapDirs(dirA, dirB);
|
|
207
|
+
// Read inventory here, while the (possibly cached/restored) dirs still exist —
|
|
208
|
+
// the finally below deletes them in cached-map mode.
|
|
209
|
+
inventoryAudit = readInventoryAudit(dirA, dirB);
|
|
138
210
|
} catch (e) {
|
|
139
211
|
console.error(e.message);
|
|
140
212
|
process.exit(2);
|
|
@@ -170,6 +242,8 @@ for (const sd of surfaces) {
|
|
|
170
242
|
if (lines.length > MAX) console.log(` ... and ${lines.length - MAX} more lines (re-run with --max ${lines.length})`);
|
|
171
243
|
}
|
|
172
244
|
|
|
245
|
+
const invRemovals = printInventoryAudit(inventoryAudit);
|
|
246
|
+
|
|
173
247
|
if (jsonOut) fs.writeFileSync(jsonOut, JSON.stringify({ counts, surfaces, compared }, null, 2));
|
|
174
248
|
|
|
175
249
|
const total = counts.dom + counts.style + counts.state;
|
|
@@ -177,13 +251,14 @@ const newSurfaces = surfaces.filter((s) => s.missing).length;
|
|
|
177
251
|
// One SurfaceDiff per distinct surface across both sides (incl. missing-on-one-side).
|
|
178
252
|
const surfaceCount = surfaces.length;
|
|
179
253
|
const newNote = newSurfaces ? ` (+${newSurfaces} new surface(s) with no baseline)` : '';
|
|
254
|
+
const invNote = invRemovals ? ` + ${invRemovals} unacknowledged inventory removal(s)` : '';
|
|
180
255
|
console.log(
|
|
181
|
-
total === 0
|
|
256
|
+
total === 0 && invRemovals === 0
|
|
182
257
|
? newSurfaces === 0
|
|
183
258
|
? `\n✓ 0 changed surfaces across ${compared} captured surface(s): every computed style, pseudo-element, and hover/focus/active state matches`
|
|
184
259
|
: `\nℹ ${newSurfaces} new surface(s) captured with no baseline to compare — review before baselining`
|
|
185
|
-
: `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}`,
|
|
260
|
+
: `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}${invNote}`,
|
|
186
261
|
);
|
|
187
|
-
// 0 = identical, 1 = reviewable differences
|
|
188
|
-
//
|
|
189
|
-
process.exit(total > 0 ? 1 : newSurfaces > 0 ? 3 : 0);
|
|
262
|
+
// 0 = identical, 1 = reviewable differences (incl. unacknowledged inventory
|
|
263
|
+
// removals), 3 = only new surfaces (no baseline). 2 reserved for usage/capture errors.
|
|
264
|
+
process.exit(total > 0 || invRemovals > 0 ? 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/capture.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Page } from '@playwright/test';
|
|
2
|
+
import { type NavigableItem } from './inventory.js';
|
|
2
3
|
/**
|
|
3
4
|
* Computed-style capture: the browser's final resolved value for every CSS
|
|
4
5
|
* longhand on every element, keyed by DOM structure (never by class name, so
|
|
@@ -124,6 +125,13 @@ export type StyleMap = {
|
|
|
124
125
|
* surface from the document root; per-subtree overrides aren't tracked.
|
|
125
126
|
*/
|
|
126
127
|
tokens?: Record<string, string>;
|
|
128
|
+
/**
|
|
129
|
+
* The surface's navigable inventory — user-reachable affordances (route links,
|
|
130
|
+
* tabs, menu items, button-only nav), keyed stably. Present only when captured
|
|
131
|
+
* with `inventory: true`. Diffed by the inventory guard (a removal gates), never
|
|
132
|
+
* by the certification diff. See docs/inventory-guard.md.
|
|
133
|
+
*/
|
|
134
|
+
inventory?: NavigableItem[];
|
|
127
135
|
};
|
|
128
136
|
export type CaptureOptions = {
|
|
129
137
|
/**
|
|
@@ -135,6 +143,12 @@ export type CaptureOptions = {
|
|
|
135
143
|
* is volatile without paying the settle wait for it.
|
|
136
144
|
*/
|
|
137
145
|
ignore?: string[];
|
|
146
|
+
/**
|
|
147
|
+
* Harvest the surface's navigable inventory (route links, tabs, menu items,
|
|
148
|
+
* button-only nav) into `StyleMap.inventory`, for the inventory guard. Off by
|
|
149
|
+
* default; advisory to the certification diff — a removal gates separately.
|
|
150
|
+
*/
|
|
151
|
+
inventory?: boolean;
|
|
138
152
|
/**
|
|
139
153
|
* Settle the page before capturing, and auto-exclude live regions (default
|
|
140
154
|
* on). StyleProof polls the (motion-frozen) page until its computed-style map
|
|
@@ -234,12 +248,6 @@ export declare function trackInflightRequests(page: Page): {
|
|
|
234
248
|
pending: () => number;
|
|
235
249
|
dispose: () => void;
|
|
236
250
|
};
|
|
237
|
-
/**
|
|
238
|
-
* Capture the page's complete style map. Drive the page to the state you want
|
|
239
|
-
* first (navigate, open menus); by default the capture then auto-settles the
|
|
240
|
-
* page and excludes live regions (see `stabilize`), so a fetch/stream that
|
|
241
|
-
* paints after `go()` resolves is captured loaded, not mid-load.
|
|
242
|
-
*/
|
|
243
251
|
export declare function captureStyleMap(page: Page, options?: CaptureOptions): Promise<StyleMap>;
|
|
244
252
|
/** Write a style map to disk; gzipped when the path ends in `.gz`. */
|
|
245
253
|
export declare function saveStyleMap(filePath: string, map: StyleMap): void;
|
package/dist/capture.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { gzipSync, gunzipSync } from 'node:zlib';
|
|
4
|
+
import { classifyInventory, collectNavAffordances } from './inventory.js';
|
|
4
5
|
const INTERACTIVE = 'a, button, input, textarea, select, summary, [role="button"], [tabindex]';
|
|
5
6
|
// Freeze motion so every captured value is a settled end state, not a frame
|
|
6
7
|
// of an animation or a mid-flight transition after a forced :hover.
|
|
@@ -354,6 +355,7 @@ function detectOverlayCandidates({ ignore }) {
|
|
|
354
355
|
}
|
|
355
356
|
/** Full (unpruned) computed styles for an element and its descendants, pseudo-elements included. */
|
|
356
357
|
// Serialized into the browser by page.evaluate; cannot call module helpers.
|
|
358
|
+
// fallow-ignore-next-line complexity -- pre-existing (cog 16); in-page fn can't call module helpers, so it can't be split. Exposed here only because the inventory feature edits this file. Refactor separately.
|
|
357
359
|
function snapSubtree({ selector, index }) {
|
|
358
360
|
const el = document.querySelectorAll(selector)[index];
|
|
359
361
|
const pathOf = (n) => {
|
|
@@ -665,6 +667,13 @@ function mergeMotion(elements, motion) {
|
|
|
665
667
|
* page and excludes live regions (see `stabilize`), so a fetch/stream that
|
|
666
668
|
* paints after `go()` resolves is captured loaded, not mid-load.
|
|
667
669
|
*/
|
|
670
|
+
/**
|
|
671
|
+
* Harvest the surface's navigable inventory when enabled; `[]` otherwise. Kept out
|
|
672
|
+
* of captureStyleMap so that function stays within the complexity budget.
|
|
673
|
+
*/
|
|
674
|
+
async function harvestInventoryFor(page, enabled) {
|
|
675
|
+
return enabled ? classifyInventory(await page.evaluate(collectNavAffordances)) : [];
|
|
676
|
+
}
|
|
668
677
|
export async function captureStyleMap(page, options = {}) {
|
|
669
678
|
// Framework/non-visual noise is always skipped, so it can't read as a DOM
|
|
670
679
|
// change; the caller's `ignore` adds to it (not replaces it).
|
|
@@ -730,6 +739,7 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
730
739
|
statesSkipped = forced.skipped;
|
|
731
740
|
}
|
|
732
741
|
const tokens = await page.evaluate(capturePageTokens);
|
|
742
|
+
const inventory = await harvestInventoryFor(page, options.inventory);
|
|
733
743
|
return {
|
|
734
744
|
...(options.metadata ? { metadata: options.metadata } : {}),
|
|
735
745
|
defaults: base.defaults,
|
|
@@ -740,6 +750,7 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
740
750
|
...(liveCandidates.length ? { liveCandidates } : {}),
|
|
741
751
|
...(overlays.length ? { overlays } : {}),
|
|
742
752
|
...(Object.keys(tokens).length ? { tokens } : {}),
|
|
753
|
+
...(inventory.length ? { inventory } : {}),
|
|
743
754
|
};
|
|
744
755
|
}
|
|
745
756
|
/** Write a style map to disk; gzipped when the path ends in `.gz`. */
|
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/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
|
|
2
|
+
export * from './inventory.js';
|
|
2
3
|
export { captureUrlToDir, runCaptureUrl, parseCaptureUrlArgs, UsageError } from './capture-url.js';
|
|
3
4
|
export type { CaptureUrlOptions, CaptureUrlResult } from './capture-url.js';
|
|
4
5
|
export { crawlAndCapture, CRAWL_DEFAULTS } from './crawl-surfaces.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
|
|
2
|
+
export * from './inventory.js';
|
|
2
3
|
export { captureUrlToDir, runCaptureUrl, parseCaptureUrlArgs, UsageError } from './capture-url.js';
|
|
3
4
|
export { crawlAndCapture, CRAWL_DEFAULTS } from './crawl-surfaces.js';
|
|
4
5
|
export { loadSetupSteps } from './capture-url.js';
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** One user-reachable navigation affordance, keyed stably across base/head. */
|
|
2
|
+
export type NavigableItem = {
|
|
3
|
+
/**
|
|
4
|
+
* Stable identity across captures. `route:<pathname><search>` for internal
|
|
5
|
+
* links; `<role>:<slug(name)>` for tabs / menu items / button-only nav — so a
|
|
6
|
+
* tab labelled "MODEL CONFIG" keys as `tab:model-config` regardless of styling,
|
|
7
|
+
* and lines up with an app's own view id.
|
|
8
|
+
*/
|
|
9
|
+
key: string;
|
|
10
|
+
kind: 'link' | 'tab' | 'menuitem' | 'nav-button';
|
|
11
|
+
/** Visible accessible name at capture time (for the report). */
|
|
12
|
+
label: string;
|
|
13
|
+
/** Resolved same-origin path, for `kind: 'link'`. */
|
|
14
|
+
href?: string;
|
|
15
|
+
};
|
|
16
|
+
export type InventoryDelta = {
|
|
17
|
+
/** Present on head, absent on base — a newly-offered affordance (informational). */
|
|
18
|
+
added: NavigableItem[];
|
|
19
|
+
/** Present on base, absent on head — a feature the UI stopped offering (gates). */
|
|
20
|
+
removed: NavigableItem[];
|
|
21
|
+
};
|
|
22
|
+
/** `key -> reason` — removals that are intentional, reviewed, and on the record. */
|
|
23
|
+
export type AllowedRemovals = Record<string, string>;
|
|
24
|
+
/** One raw navigable affordance as read from the DOM, before classification. */
|
|
25
|
+
export type RawAffordance = {
|
|
26
|
+
tag: string;
|
|
27
|
+
role: string;
|
|
28
|
+
name: string;
|
|
29
|
+
/** pathname+search for a same-origin `<a href>`; null otherwise. Resolved in-page. */
|
|
30
|
+
internalPath: string | null;
|
|
31
|
+
};
|
|
32
|
+
/** In-page: collect visible navigable affordances. No classification. */
|
|
33
|
+
export declare function collectNavAffordances(): RawAffordance[];
|
|
34
|
+
/** Pure: turn raw affordances into keyed, deduped, sorted navigable items. */
|
|
35
|
+
export declare function classifyInventory(raw: RawAffordance[]): NavigableItem[];
|
|
36
|
+
/** Harvest a page's inventory: thin in-page collect + pure classify. */
|
|
37
|
+
export declare function harvestInventory(page: {
|
|
38
|
+
evaluate: <T>(fn: () => T) => Promise<T>;
|
|
39
|
+
}): Promise<NavigableItem[]>;
|
|
40
|
+
/** Union the per-surface inventories of a whole run into one reachable set. */
|
|
41
|
+
export declare function unionInventory(perSurface: Array<{
|
|
42
|
+
inventory?: NavigableItem[];
|
|
43
|
+
} | undefined>): NavigableItem[];
|
|
44
|
+
/** Base vs head reachable sets → what the UI newly offers / stopped offering. */
|
|
45
|
+
export declare function diffInventory(base: NavigableItem[], head: NavigableItem[]): InventoryDelta;
|
|
46
|
+
/**
|
|
47
|
+
* The gate. Removals that aren't acknowledged in `allowed` (key -> reason) are
|
|
48
|
+
* unexplained — the caller fails on a non-empty result. An `allowed` key that
|
|
49
|
+
* isn't actually removed is a stale acknowledgement, returned separately so the
|
|
50
|
+
* ledger can't quietly rot (mirrors the `exclude` coverage guard).
|
|
51
|
+
*/
|
|
52
|
+
export declare function auditRemovals(delta: InventoryDelta, allowed?: AllowedRemovals): {
|
|
53
|
+
unexplained: NavigableItem[];
|
|
54
|
+
staleAllowances: string[];
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Run-level entry point: union both sides' per-surface `map.inventory`, diff, and
|
|
58
|
+
* audit removals. This is what a gate calls — pass every base map and every head
|
|
59
|
+
* map (the reachable set is the union across all surfaces). `unexplained` non-empty
|
|
60
|
+
* ⇒ the gate should fail; `staleAllowances` non-empty ⇒ prune the ledger.
|
|
61
|
+
*/
|
|
62
|
+
export declare function auditRunInventory(baseMaps: Array<{
|
|
63
|
+
inventory?: NavigableItem[];
|
|
64
|
+
} | undefined>, headMaps: Array<{
|
|
65
|
+
inventory?: NavigableItem[];
|
|
66
|
+
} | undefined>, allowed?: AllowedRemovals): {
|
|
67
|
+
delta: InventoryDelta;
|
|
68
|
+
unexplained: NavigableItem[];
|
|
69
|
+
staleAllowances: string[];
|
|
70
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Inventory guard — assert the navigable UI doesn't silently shrink.
|
|
2
|
+
//
|
|
3
|
+
// StyleProof's certification diff answers "did surface X change between base and
|
|
4
|
+
// head?" — a same-key regression check. It is structurally blind to a whole class
|
|
5
|
+
// of high-stakes change: a redesign delivered as a NEW surface beside the old one
|
|
6
|
+
// (the diff is old-vs-old, clean), or a nav item / route that DISAPPEARS (a feature
|
|
7
|
+
// stops being reachable). Those aren't restyles — they're the reachable set of the
|
|
8
|
+
// UI shrinking, which is an information-architecture change the pixel diff catches
|
|
9
|
+
// only incidentally, if at all.
|
|
10
|
+
//
|
|
11
|
+
// This module harvests the *navigable inventory* of each captured surface — the
|
|
12
|
+
// user-reachable affordances (route links, tabs, menu items, button-only SPA nav) —
|
|
13
|
+
// keyed by a stable id, then diffs the UNION across a run. A key present on base but
|
|
14
|
+
// absent on head is a REMOVAL: a feature the UI no longer offers. Removals gate
|
|
15
|
+
// (like the `exclude` coverage ledger) unless explicitly acknowledged with a reason,
|
|
16
|
+
// so "we dropped Model Config" is a decision on the record, never a silent green.
|
|
17
|
+
//
|
|
18
|
+
// The harvest (`collectNavAffordances`) runs in-page, mirroring
|
|
19
|
+
// `detectOverlayCandidates`; the diff/union/guard are pure and unit-testable.
|
|
20
|
+
// ── in-page harvest ───────────────────────────────────────────────────────────
|
|
21
|
+
// Split in two: the DOM-touching half stays thin (and serializable to the page,
|
|
22
|
+
// like detectOverlayCandidates); the classification is pure and unit-testable.
|
|
23
|
+
/** In-page: collect visible navigable affordances. No classification. */
|
|
24
|
+
export function collectNavAffordances() {
|
|
25
|
+
const visible = (el) => {
|
|
26
|
+
if (el.hidden || el.getAttribute('aria-hidden') === 'true')
|
|
27
|
+
return false;
|
|
28
|
+
const r = el.getBoundingClientRect();
|
|
29
|
+
const s = getComputedStyle(el);
|
|
30
|
+
return r.width > 0 && r.height > 0 && s.display !== 'none' && s.visibility !== 'hidden';
|
|
31
|
+
};
|
|
32
|
+
const nameOf = (el) => (el.getAttribute('aria-label') || el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
33
|
+
const internalPath = (el) => {
|
|
34
|
+
const raw = (el.getAttribute('href') || '').trim();
|
|
35
|
+
if (!raw || raw.startsWith('#'))
|
|
36
|
+
return null;
|
|
37
|
+
try {
|
|
38
|
+
const u = new URL(raw, location.href);
|
|
39
|
+
return u.origin === location.origin ? `${u.pathname}${u.search}` : null;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
// Semantic nav first (a[href], role=tab/menuitem, <nav>/tablist buttons); then a
|
|
46
|
+
// conservative class heuristic for button-only navs that skip ARIA — a container
|
|
47
|
+
// whose class strongly implies navigation (nav / navtab / subnav / tabs / subtab).
|
|
48
|
+
// Erring broad is correct here: a stray non-nav button is harmless noise, but a
|
|
49
|
+
// MISSED nav item defeats the guard. Prefer semantic markup (role=tablist) for
|
|
50
|
+
// fully reliable harvesting; see docs/inventory-guard.md.
|
|
51
|
+
const SEL = 'a[href], [role="tab"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], nav button, [role="navigation"] button, [role="tablist"] button, [class*="navtab" i] button, [class*="nav-tab" i] button, [class*="subnav" i] button, [class*="subtab" i] button, [class*="tabs" i] button';
|
|
52
|
+
return Array.from(document.querySelectorAll(SEL))
|
|
53
|
+
.filter(visible)
|
|
54
|
+
.map((el) => ({
|
|
55
|
+
tag: el.tagName.toLowerCase(),
|
|
56
|
+
role: (el.getAttribute('role') || '').toLowerCase(),
|
|
57
|
+
name: nameOf(el),
|
|
58
|
+
internalPath: el.tagName === 'A' ? internalPath(el) : null,
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
const slug = (s) => s
|
|
62
|
+
.toLowerCase()
|
|
63
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
64
|
+
.replace(/^-+|-+$/g, '')
|
|
65
|
+
.slice(0, 60);
|
|
66
|
+
/** Pure: turn raw affordances into keyed, deduped, sorted navigable items. */
|
|
67
|
+
export function classifyInventory(raw) {
|
|
68
|
+
const items = new Map();
|
|
69
|
+
const add = (key, kind, label, href) => {
|
|
70
|
+
if (key && !items.has(key))
|
|
71
|
+
items.set(key, href ? { key, kind, label, href } : { key, kind, label });
|
|
72
|
+
};
|
|
73
|
+
for (const c of raw) {
|
|
74
|
+
if (c.tag === 'a' && c.internalPath) {
|
|
75
|
+
add(`route:${c.internalPath}`, 'link', c.name || c.internalPath, c.internalPath);
|
|
76
|
+
}
|
|
77
|
+
else if (c.name && c.role === 'tab') {
|
|
78
|
+
add(`tab:${slug(c.name)}`, 'tab', c.name);
|
|
79
|
+
}
|
|
80
|
+
else if (c.name && c.role.startsWith('menuitem')) {
|
|
81
|
+
add(`menuitem:${slug(c.name)}`, 'menuitem', c.name);
|
|
82
|
+
}
|
|
83
|
+
else if (c.name && c.tag === 'button') {
|
|
84
|
+
add(`nav-button:${slug(c.name)}`, 'nav-button', c.name);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return Array.from(items.values()).sort((a, b) => a.key.localeCompare(b.key));
|
|
88
|
+
}
|
|
89
|
+
/** Harvest a page's inventory: thin in-page collect + pure classify. */
|
|
90
|
+
export async function harvestInventory(page) {
|
|
91
|
+
return classifyInventory(await page.evaluate(collectNavAffordances));
|
|
92
|
+
}
|
|
93
|
+
// ── pure diff / union / guard ───────────────────────────────────────────────────
|
|
94
|
+
/** Union the per-surface inventories of a whole run into one reachable set. */
|
|
95
|
+
export function unionInventory(perSurface) {
|
|
96
|
+
const byKey = new Map();
|
|
97
|
+
for (const map of perSurface) {
|
|
98
|
+
for (const item of map?.inventory ?? [])
|
|
99
|
+
if (!byKey.has(item.key))
|
|
100
|
+
byKey.set(item.key, item);
|
|
101
|
+
}
|
|
102
|
+
return Array.from(byKey.values()).sort((a, b) => a.key.localeCompare(b.key));
|
|
103
|
+
}
|
|
104
|
+
/** Base vs head reachable sets → what the UI newly offers / stopped offering. */
|
|
105
|
+
export function diffInventory(base, head) {
|
|
106
|
+
const headKeys = new Set(head.map((i) => i.key));
|
|
107
|
+
const baseKeys = new Set(base.map((i) => i.key));
|
|
108
|
+
return {
|
|
109
|
+
added: head.filter((i) => !baseKeys.has(i.key)),
|
|
110
|
+
removed: base.filter((i) => !headKeys.has(i.key)),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The gate. Removals that aren't acknowledged in `allowed` (key -> reason) are
|
|
115
|
+
* unexplained — the caller fails on a non-empty result. An `allowed` key that
|
|
116
|
+
* isn't actually removed is a stale acknowledgement, returned separately so the
|
|
117
|
+
* ledger can't quietly rot (mirrors the `exclude` coverage guard).
|
|
118
|
+
*/
|
|
119
|
+
export function auditRemovals(delta, allowed = {}) {
|
|
120
|
+
const removedKeys = new Set(delta.removed.map((i) => i.key));
|
|
121
|
+
return {
|
|
122
|
+
unexplained: delta.removed.filter((i) => !(i.key in allowed)),
|
|
123
|
+
staleAllowances: Object.keys(allowed).filter((k) => !removedKeys.has(k)),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Run-level entry point: union both sides' per-surface `map.inventory`, diff, and
|
|
128
|
+
* audit removals. This is what a gate calls — pass every base map and every head
|
|
129
|
+
* map (the reachable set is the union across all surfaces). `unexplained` non-empty
|
|
130
|
+
* ⇒ the gate should fail; `staleAllowances` non-empty ⇒ prune the ledger.
|
|
131
|
+
*/
|
|
132
|
+
export function auditRunInventory(baseMaps, headMaps, allowed = {}) {
|
|
133
|
+
const delta = diffInventory(unionInventory(baseMaps), unionInventory(headMaps));
|
|
134
|
+
return { delta, ...auditRemovals(delta, allowed) };
|
|
135
|
+
}
|
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>;
|
|
@@ -238,10 +247,16 @@ export type CrawlOptions = CaptureConfig & {
|
|
|
238
247
|
match?: LinkMatch;
|
|
239
248
|
/** Derive a surface key from a link URL. Default: path+query slug (`/?tab=x` → `x`). */
|
|
240
249
|
key?: (url: URL) => string;
|
|
241
|
-
/** Viewport widths swept for every discovered surface
|
|
242
|
-
|
|
250
|
+
/** Viewport widths swept for every discovered surface. Omit to auto-detect each
|
|
251
|
+
* surface's @media breakpoints (one viewport per band) — the same zero-config
|
|
252
|
+
* behaviour as an explicit surface with no `widths`. */
|
|
253
|
+
widths?: number[];
|
|
243
254
|
/** Viewport height per width (default 800). */
|
|
244
255
|
height?: number | ((width: number) => number);
|
|
256
|
+
/** Run after navigating to each discovered link, before capture — e.g. to trigger
|
|
257
|
+
* scroll-reveal content. The built-in font/animation/network settle always runs;
|
|
258
|
+
* this is the app-specific hook, the crawl's parity with a hand-listed surface's `go`. */
|
|
259
|
+
settle?: (page: Page) => Promise<void>;
|
|
245
260
|
/** Selectors skipped on every surface (live regions, third-party embeds). */
|
|
246
261
|
ignore?: string[];
|
|
247
262
|
/** Deterministic variants captured for every discovered link surface. */
|
package/dist/runner.js
CHANGED
|
@@ -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
|
/**
|
|
@@ -517,17 +519,28 @@ export function defineStyleMapCapture(options) {
|
|
|
517
519
|
* ```
|
|
518
520
|
*/
|
|
519
521
|
export function defineCrawlCapture(options) {
|
|
520
|
-
const { from, match, key, widths, height, ignore, variants, liveStates, popups, linkTimeout = 15_000, dir } = options;
|
|
522
|
+
const { from, match, key, widths, height, ignore, variants, liveStates, popups, linkTimeout = 15_000, dir, settle, } = options;
|
|
521
523
|
const settings = resolveSettings(options);
|
|
522
|
-
|
|
524
|
+
// Title contains "styleproof capture" so the same `--grep 'styleproof capture'`
|
|
525
|
+
// that styleproof-map uses to select capture tests picks up crawl specs too.
|
|
526
|
+
test.describe('styleproof capture (crawl)', () => {
|
|
523
527
|
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
524
528
|
test('discover surfaces by crawling links, then capture each', async ({ page }) => {
|
|
525
529
|
// 1. Load the root and wait for its nav links to hydrate — an SPA renders them
|
|
526
530
|
// client-side, so they aren't in the initial HTML.
|
|
527
531
|
await page.goto(from, { waitUntil: 'load' });
|
|
528
|
-
|
|
532
|
+
// Wait for the nav's links to hydrate (an SPA renders them client-side). A link-less
|
|
533
|
+
// page is fine for an unfiltered crawl — it still captures `from` itself (includeSelf);
|
|
534
|
+
// a `match`-filtered crawl genuinely needs links, so let its timeout surface.
|
|
535
|
+
await page.waitForSelector('a[href]', { timeout: linkTimeout }).catch((e) => {
|
|
536
|
+
if (match !== undefined)
|
|
537
|
+
throw e;
|
|
538
|
+
});
|
|
529
539
|
const hrefs = await page.$$eval('a[href]', (els) => els.map((e) => e.getAttribute('href')));
|
|
530
|
-
|
|
540
|
+
// Unfiltered crawl → also capture `from` itself, so the root is always covered and
|
|
541
|
+
// a single-page (or no-nav-self-link) app still yields a surface. A `match`-filtered
|
|
542
|
+
// crawl captures only the links the caller asked for.
|
|
543
|
+
const links = selectCrawlLinks(hrefs, { base: page.url(), match, key, includeSelf: match === undefined });
|
|
531
544
|
if (links.length === 0) {
|
|
532
545
|
throw new Error(`styleproof crawl: no links matched at ${from}. The nav must render same-origin ` +
|
|
533
546
|
`<a href> links (a button-only nav exposes nothing to crawl), and \`match\` must keep them.`);
|
|
@@ -536,6 +549,8 @@ export function defineCrawlCapture(options) {
|
|
|
536
549
|
key: link.key,
|
|
537
550
|
go: async (p) => {
|
|
538
551
|
await p.goto(link.url, { waitUntil: 'load' });
|
|
552
|
+
if (settle)
|
|
553
|
+
await settle(p);
|
|
539
554
|
},
|
|
540
555
|
widths,
|
|
541
556
|
ignore,
|
|
@@ -546,12 +561,27 @@ export function defineCrawlCapture(options) {
|
|
|
546
561
|
}));
|
|
547
562
|
// Budget the whole sweep up front: one test captures every surface, and
|
|
548
563
|
// captureSurface no longer sets its own timeout, so size it to the work found.
|
|
549
|
-
|
|
564
|
+
// With auto-width the band count isn't known until each surface renders, so
|
|
565
|
+
// assume up to 4 bands per surface.
|
|
566
|
+
test.setTimeout(Math.max(180_000, captureSurfaces.reduce((sum, surface) => sum + (surface.widths?.length ?? 4), 0) * 60_000));
|
|
550
567
|
// 2. Capture each discovered surface. Aggregate failures so one bad surface
|
|
551
568
|
// reports without skipping the rest — they're an independent set, not a chain.
|
|
552
569
|
const failures = [];
|
|
553
570
|
for (const surface of captureSurfaces) {
|
|
554
|
-
|
|
571
|
+
// Auto-width parity with explicit surfaces: no widths given → navigate once and
|
|
572
|
+
// detect this surface's @media bands, then sweep one viewport per band.
|
|
573
|
+
let sweep = surface.widths;
|
|
574
|
+
if (!sweep || sweep.length === 0) {
|
|
575
|
+
try {
|
|
576
|
+
await surface.go(page);
|
|
577
|
+
sweep = await detectViewportWidths(page);
|
|
578
|
+
}
|
|
579
|
+
catch (e) {
|
|
580
|
+
failures.push(`${surface.key} @ auto: ${e.message}`);
|
|
581
|
+
continue;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
for (const width of sweep) {
|
|
555
585
|
try {
|
|
556
586
|
await captureSurface(page, surface, width, settings);
|
|
557
587
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.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",
|