styleproof 3.5.0 → 3.7.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 +100 -17
- package/bin/styleproof-capture.mjs +1 -0
- package/bin/styleproof-diff.mjs +80 -5
- package/dist/capture-url.d.ts +5 -0
- package/dist/capture-url.js +3 -0
- package/dist/capture.d.ts +14 -6
- package/dist/capture.js +11 -0
- package/dist/crawl-surfaces.d.ts +8 -0
- package/dist/crawl-surfaces.js +186 -25
- 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/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,61 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.7.0] - 2026-07-04
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Inventory guard** — StyleProof can now assert the navigable UI doesn't silently
|
|
15
|
+
shrink. Opt in with `captureStyleMap(page, { inventory: true })` and each surface's
|
|
16
|
+
navigable affordances — internal route links, `role=tab`, `role=menuitem`,
|
|
17
|
+
button-only nav — are harvested (keyed stably) into `StyleMap.inventory`.
|
|
18
|
+
`styleproof-diff` then unions the reachable set across both sides and **exits 1 on
|
|
19
|
+
any affordance present on base but absent on head** — a feature that stopped being
|
|
20
|
+
reachable — unless it's acknowledged in `styleproof.inventory.json` (`{"<key>":
|
|
21
|
+
"<why>"}`, path overridable via `STYLEPROOF_INVENTORY`; a stale acknowledgement is
|
|
22
|
+
flagged so the ledger can't rot). Closes the certification diff's blind spot for the
|
|
23
|
+
information-architecture / replacement class: a redesign staged as a new surface, or
|
|
24
|
+
a nav item / route that disappears, which a same-surface computed-style diff catches
|
|
25
|
+
only incidentally. **Off by default (no map carries inventory ⇒ the CLI is
|
|
26
|
+
byte-for-byte unchanged); the certification diff itself is untouched.** The
|
|
27
|
+
programmatic entry point `auditRunInventory(baseMaps, headMaps, allowRemoved)`
|
|
28
|
+
stays available for custom gates. See `docs/inventory-guard.md`.
|
|
29
|
+
|
|
30
|
+
## [3.6.0] - 2026-07-03
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- Crawl candidate collection collapses inherited-cursor subtrees to their
|
|
35
|
+
OUTERMOST clickable. `cursor` is an inherited CSS property, so a clickable card
|
|
36
|
+
makes every descendant compute `cursor: pointer`; each became its own candidate
|
|
37
|
+
even though clicking any of them just bubbles to the card's handler (the same
|
|
38
|
+
surface). Each redundant candidate paid a drive + verified reset — the dominant
|
|
39
|
+
cost of a large crawl. Measured on a 241-class design: base candidates 557 → 22,
|
|
40
|
+
whole coverage crawl ~40min → ~6min (240/241, unchanged). Semantic controls
|
|
41
|
+
nested in a clickable container are still kept.
|
|
42
|
+
- `--until-covered` reaches deep coverage: it now terminates on the queue
|
|
43
|
+
draining (or full coverage), not a fixed "N surfaces without a new class"
|
|
44
|
+
plateau — a plateau cut the crawl off before a productive deep state (an
|
|
45
|
+
automation whose expandable run row is its last candidate) was ever swept.
|
|
46
|
+
Coverage mode also (a) prunes the queue to surfaces that add new render
|
|
47
|
+
vocabulary — a structural repeat like the ninth agent's identical dossier is
|
|
48
|
+
captured once but not re-drilled — and (b) uses the breadth-first queue rather
|
|
49
|
+
than the depth-first in-place descent, so distinct components are reached fast
|
|
50
|
+
and each drilled once via now-reliable resets. Measured on a 241-class design:
|
|
51
|
+
240/241 (the one gap a post-decision state reachable only with a setup step),
|
|
52
|
+
where a plateau-stopped crawl left the deep automation run-detail unmapped.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- Reset-replay to depth >= 2 no longer fails: the crawler's structural
|
|
57
|
+
fingerprint counted StyleProof's OWN injected hover-sink `<div>` (added during
|
|
58
|
+
a capture, so present when a state is captured in place but absent on a fresh
|
|
59
|
+
reset+replay). Every such reset failed its fingerprint verification, so any
|
|
60
|
+
surface reachable ONLY by re-driving from a deep state — a pairwise mode
|
|
61
|
+
combination like a tab's edit view — was silently lost. The fingerprint now
|
|
62
|
+
excludes the sink (and framework route-announcers). Measured: depth-2..5
|
|
63
|
+
resets went from all-fail to all-pass.
|
|
64
|
+
|
|
10
65
|
### Fixed
|
|
11
66
|
|
|
12
67
|
- Report tables never show an equal-looking Before/After pair for a real diff.
|
|
@@ -20,23 +75,51 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
20
75
|
|
|
21
76
|
### Changed
|
|
22
77
|
|
|
23
|
-
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
78
|
+
- Crawl reset settle no longer waits on `networkidle`: it polls DOM-growth to
|
|
79
|
+
detect the mounted app (unchanged) and then waits for `document.fonts.ready`
|
|
80
|
+
specifically (fonts are part of the computed style the diff compares, so they
|
|
81
|
+
must be loaded — but that is the deterministic signal, cache-warm on repeat
|
|
82
|
+
loads). networkidle waited a 500ms idle window ON TOP of a cross-origin font
|
|
83
|
+
sheet that lingered ~1s per load with no bearing on readiness; since every
|
|
84
|
+
state reset re-navigates, that dominated crawl time. Measured: settle 911ms →
|
|
85
|
+
105ms per reset (~8.7x), whole-crawl surface rate ~5/min → ~17/min.
|
|
86
|
+
|
|
87
|
+
- Crawl is now breadth-first (was depth-first): every shallow surface — nav
|
|
88
|
+
tabs, opened panels, the tabs inside a dossier — is exhausted before drilling
|
|
89
|
+
deeper. Depth-first starved breadth: one append-generator branch drilled past
|
|
90
|
+
depth 20 while sibling tabs sat unvisited, so real surfaces (an OAuth card, a
|
|
91
|
+
skills grid, a markdown editor) went uncaptured — measured live as 48 defined
|
|
92
|
+
classes never rendered across ~12 surfaces. Dedup is set-based, so order
|
|
93
|
+
changes only WHICH surface is found first, never the final set.
|
|
94
|
+
- `maxDepth` default 1000 -> 16: exhaustive for real UI (nothing human-navigable
|
|
95
|
+
is 16 clicks from load) while terminating append-generator chains, whose every
|
|
96
|
+
appended node is a fresh tag-path identity. The coverage verifier still names
|
|
97
|
+
any class left unrendered, so a too-low cap fails loudly rather than lying.
|
|
98
|
+
|
|
99
|
+
- The never-click guard now also covers state-mutating verbs (rotate,
|
|
100
|
+
provision, seal, regenerate, renew): mapping must not mutate, and a mutating
|
|
101
|
+
control that persists after its click re-labels its surroundings with fresh
|
|
102
|
+
data on every press — an unbounded mutation farm a crawl must not walk.
|
|
103
|
+
Observed live: credential-rotation cells minted new identities per press,
|
|
104
|
+
inflating a crawl past 470 surfaces at depth 23. Their render states are
|
|
105
|
+
seed-data territory (and anything unreached is named by the verifier).
|
|
106
|
+
- Freshly-opened surfaces are swept IN PLACE (forward-drive), not only queued
|
|
107
|
+
for a later reset+replay. Reaching a surface by a forward click is reliable;
|
|
108
|
+
re-reaching it by reset is slow (a reset per candidate) and, at depth, starved
|
|
109
|
+
— a dossier's many filter combinations flood the queue and bury the deep
|
|
110
|
+
sweep, so an expanded run row inside an expanded job was captured 0 times
|
|
111
|
+
despite being reachable (now 29). The descent drives only fresh controls new
|
|
112
|
+
to the surface (excludes parent-present mode-switchers), so pairwise mode
|
|
113
|
+
coverage is unchanged and the lattice stays pairwise.
|
|
114
|
+
|
|
115
|
+
### Added
|
|
116
|
+
|
|
117
|
+
- `--until-covered`: stop the crawl as soon as every class the page's
|
|
118
|
+
stylesheets define has rendered (full coverage) or coverage stops improving
|
|
119
|
+
(no new class for a plateau of surfaces). Turns an exhaustive crawl into a
|
|
120
|
+
fast coverage check that stops once it has SEEN everything, instead of
|
|
121
|
+
enumerating every combinatorial surface that adds no new vocabulary. Opt-in;
|
|
122
|
+
exhaustive remains the default.
|
|
40
123
|
|
|
41
124
|
## [3.5.0] - 2026-07-02
|
|
42
125
|
|
|
@@ -107,6 +107,7 @@ async function runCrawl() {
|
|
|
107
107
|
resetStorage: opts.resetStorage,
|
|
108
108
|
setup: setupSteps,
|
|
109
109
|
dataStates: opts.dataStates,
|
|
110
|
+
stopWhenCovered: opts.untilCovered,
|
|
110
111
|
workers: opts.workers,
|
|
111
112
|
// each worker page in its OWN context, so storage resets can't interfere
|
|
112
113
|
newPage: async () => (await browser.newContext()).newPage(),
|
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/dist/capture-url.d.ts
CHANGED
|
@@ -56,6 +56,11 @@ export type CaptureUrlOptions = {
|
|
|
56
56
|
/** crawl: exit non-zero unless every class the page's stylesheets define was
|
|
57
57
|
* rendered in at least one captured surface (default false — report only). */
|
|
58
58
|
requireFullCoverage: boolean;
|
|
59
|
+
/** crawl: stop as soon as coverage is complete (every defined class seen) or
|
|
60
|
+
* has converged (no new class for a plateau of surfaces). Turns the crawl
|
|
61
|
+
* into a FAST coverage check that stops once it has seen everything, instead
|
|
62
|
+
* of enumerating every combinatorial surface. Default false (exhaustive). */
|
|
63
|
+
untilCovered: boolean;
|
|
59
64
|
/** crawl: JSON file of deterministic setup steps (login, unlock, seed input)
|
|
60
65
|
* run after every fresh navigation. See {@link loadSetupSteps}. */
|
|
61
66
|
setupFile?: string;
|
package/dist/capture-url.js
CHANGED
|
@@ -31,6 +31,7 @@ const DEFAULTS = {
|
|
|
31
31
|
maxStates: 100000,
|
|
32
32
|
resetStorage: true,
|
|
33
33
|
requireFullCoverage: false,
|
|
34
|
+
untilCovered: false,
|
|
34
35
|
dataStates: true,
|
|
35
36
|
workers: 4,
|
|
36
37
|
};
|
|
@@ -71,6 +72,7 @@ const BOOL_FLAGS = {
|
|
|
71
72
|
'--crawl': (o) => (o.crawl = true),
|
|
72
73
|
'--no-reset-storage': (o) => (o.resetStorage = false),
|
|
73
74
|
'--require-full-coverage': (o) => (o.requireFullCoverage = true),
|
|
75
|
+
'--until-covered': (o) => (o.untilCovered = true),
|
|
74
76
|
'--data-states': (o) => (o.dataStates = true),
|
|
75
77
|
'--no-data-states': (o) => (o.dataStates = false),
|
|
76
78
|
};
|
|
@@ -119,6 +121,7 @@ export function parseCaptureUrlArgs(argv) {
|
|
|
119
121
|
maxStates: DEFAULTS.maxStates,
|
|
120
122
|
resetStorage: DEFAULTS.resetStorage,
|
|
121
123
|
requireFullCoverage: DEFAULTS.requireFullCoverage,
|
|
124
|
+
untilCovered: DEFAULTS.untilCovered,
|
|
122
125
|
setupFile: undefined,
|
|
123
126
|
dataStates: DEFAULTS.dataStates,
|
|
124
127
|
workers: DEFAULTS.workers,
|
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-surfaces.d.ts
CHANGED
|
@@ -90,6 +90,14 @@ export type SurfaceCrawlOptions = {
|
|
|
90
90
|
* requests stalled — the skeleton) and `error` (data requests fulfilled with
|
|
91
91
|
* a 500). Default true; states that render identically to base are skipped. */
|
|
92
92
|
dataStates?: boolean;
|
|
93
|
+
/** Coverage-oriented termination: stop as soon as every class the page's
|
|
94
|
+
* stylesheets define has been rendered (full coverage) OR coverage stops
|
|
95
|
+
* improving (no new class for a plateau of surfaces — it has converged short
|
|
96
|
+
* of full, and the rest is dead CSS or a genuinely-gated state). Off by
|
|
97
|
+
* default (exhaustive: map every surface). On, the crawl is a fast coverage
|
|
98
|
+
* check — it stops the moment it has SEEN everything, instead of enumerating
|
|
99
|
+
* every combinatorial surface that adds no new vocabulary. */
|
|
100
|
+
stopWhenCovered?: boolean;
|
|
93
101
|
/** Throttle: recursion depth into opened surfaces (base = 0). Default: unbounded. */
|
|
94
102
|
maxDepth: number;
|
|
95
103
|
/** Throttle: fresh controls driven per state. Default: unbounded — try them all. */
|
package/dist/crawl-surfaces.js
CHANGED
|
@@ -7,7 +7,14 @@ import { detectViewportWidths } from './breakpoints.js';
|
|
|
7
7
|
export const CRAWL_DEFAULTS = {
|
|
8
8
|
height: 900,
|
|
9
9
|
screenshots: true,
|
|
10
|
-
|
|
10
|
+
// ponytail: depth 16 is exhaustive for real UI — no human-navigable surface
|
|
11
|
+
// is 16 clicks from load. Past that you're not finding surfaces, you're
|
|
12
|
+
// riding an append-generator (a composer that adds a row per click) whose
|
|
13
|
+
// every appended node is a fresh tag-path identity, so it recurses forever.
|
|
14
|
+
// The cap terminates those chains; the coverage verifier still NAMES any
|
|
15
|
+
// class left unrendered, so a too-low cap fails loudly rather than lying.
|
|
16
|
+
// Raise with --max-depth if a design genuinely nests deeper.
|
|
17
|
+
maxDepth: 16,
|
|
11
18
|
maxActionsPerState: 100000,
|
|
12
19
|
maxStates: 100000,
|
|
13
20
|
resetStorage: true,
|
|
@@ -41,7 +48,7 @@ function deriveKey(steps, used) {
|
|
|
41
48
|
/* c8 ignore start */ // fallow-ignore-next-line complexity
|
|
42
49
|
function collectClickable() {
|
|
43
50
|
const SEMANTIC = 'button,summary,[role="button"],[role="tab"],[role="menuitem"],[role="combobox"],select,form';
|
|
44
|
-
const DANGER = /\b(delete|remove|destroy|logout|log ?out|sign ?out|publish|deploy|pay|purchase|buy|checkout|archive|disconnect|revoke|reset|wipe|drop)\b/i;
|
|
51
|
+
const DANGER = /\b(delete|remove|destroy|logout|log ?out|sign ?out|publish|deploy|pay|purchase|buy|checkout|archive|disconnect|revoke|reset|wipe|drop|rotate|provision|seal|regenerate|renew)\b/i;
|
|
45
52
|
const esc = (v) => CSS.escape(v);
|
|
46
53
|
const quote = (v) => JSON.stringify(v);
|
|
47
54
|
const visible = (el) => {
|
|
@@ -99,7 +106,28 @@ function collectClickable() {
|
|
|
99
106
|
if (pool.has(el))
|
|
100
107
|
continue;
|
|
101
108
|
const cursor = getComputedStyle(el).cursor;
|
|
102
|
-
if (cursor
|
|
109
|
+
if (cursor !== 'pointer' && cursor !== 'grab')
|
|
110
|
+
continue;
|
|
111
|
+
// `cursor` is an INHERITED property: a clickable card makes every descendant
|
|
112
|
+
// compute cursor:pointer, and clicking any descendant just bubbles to the
|
|
113
|
+
// card's own handler — the SAME surface. Left unchecked, a card with N
|
|
114
|
+
// children becomes N+1 candidates, each paying a drive + verified reset to
|
|
115
|
+
// map one surface (the dominant cost of a large crawl). Add only the
|
|
116
|
+
// OUTERMOST clickable in an inherited-cursor subtree: skip when an ancestor
|
|
117
|
+
// is already a candidate. Semantic controls (button, a, [role]) are seeded
|
|
118
|
+
// above and skipped by the guard at the top of the loop, so a real button
|
|
119
|
+
// nested inside a clickable card is never dropped. (querySelectorAll walks
|
|
120
|
+
// document order, so an ancestor is always pooled before its descendants.)
|
|
121
|
+
let anc = el.parentElement;
|
|
122
|
+
let nested = false;
|
|
123
|
+
while (anc && anc !== document.body) {
|
|
124
|
+
if (pool.has(anc)) {
|
|
125
|
+
nested = true;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
anc = anc.parentElement;
|
|
129
|
+
}
|
|
130
|
+
if (!nested)
|
|
103
131
|
pool.add(el);
|
|
104
132
|
}
|
|
105
133
|
// Neutral text inputs are typed automatically with a deterministic value —
|
|
@@ -203,6 +231,17 @@ function domShape() {
|
|
|
203
231
|
for (const el of document.body.getElementsByTagName('*')) {
|
|
204
232
|
if (SKIP.has(el.tagName))
|
|
205
233
|
continue;
|
|
234
|
+
// Skip non-page artifacts that pollute the fingerprint: StyleProof's own
|
|
235
|
+
// injected hover-sink (added by a capture, so present when a state is
|
|
236
|
+
// captured in place but NOT on a fresh load) and framework route-announcers.
|
|
237
|
+
// Counting them made a state's in-place fingerprint differ from its
|
|
238
|
+
// reset+replay fingerprint, so every reset to depth >= 2 failed verification
|
|
239
|
+
// (and the same pollution split dedup, capturing one surface as two). They
|
|
240
|
+
// are not part of the page.
|
|
241
|
+
if (el.hasAttribute('data-styleproof-hover-sink'))
|
|
242
|
+
continue;
|
|
243
|
+
if (el.tagName === 'NEXT-ROUTE-ANNOUNCER' || el.id === '__next-route-announcer__')
|
|
244
|
+
continue;
|
|
206
245
|
add(el);
|
|
207
246
|
}
|
|
208
247
|
return { shape: parts.join('\n'), elements: parts.length, classes: [...classes] };
|
|
@@ -267,15 +306,27 @@ async function settleDom(page, maxMs = 1200) {
|
|
|
267
306
|
* non-trivial. Generic — no app-specific selector needed, so a bare crawl of a
|
|
268
307
|
* Babel/React/Vue page that boots after `load` still captures the mounted UI. */
|
|
269
308
|
async function waitSettled(page) {
|
|
270
|
-
|
|
309
|
+
// DOM-growth settle is the PRIMARY readiness signal — it detects an
|
|
310
|
+
// async-mounted app (the DOM stops growing and is non-trivial) without an
|
|
311
|
+
// app-specific selector, and it also catches fetch-painted content (that
|
|
312
|
+
// grows the DOM). We deliberately do NOT gate on networkidle: it waits a
|
|
313
|
+
// 500ms idle window ON TOP of any lingering request, and a single cross-origin
|
|
314
|
+
// asset — a Google-Fonts stylesheet — keeps the network "busy" ~1s per load
|
|
315
|
+
// with no bearing on readiness. gotoFresh runs once per state (plus per
|
|
316
|
+
// retry), so that dominated crawl time (measured: ~995ms of every ~1531ms
|
|
317
|
+
// reset). Instead we wait for FONTS specifically — they ARE part of the
|
|
318
|
+
// computed style the diff compares, so they must be loaded before capture,
|
|
319
|
+
// but document.fonts.ready is the deterministic signal (and resolves from the
|
|
320
|
+
// page's cache on repeat loads, so it's ~free after the first).
|
|
271
321
|
let prev = -1;
|
|
272
322
|
for (let i = 0; i < 40; i++) {
|
|
273
323
|
const n = await page.evaluate(() => document.body.getElementsByTagName('*').length);
|
|
274
324
|
if (n > 5 && n === prev)
|
|
275
|
-
|
|
325
|
+
break;
|
|
276
326
|
prev = n;
|
|
277
327
|
await page.waitForTimeout(100);
|
|
278
328
|
}
|
|
329
|
+
await page.evaluate(() => document.fonts.ready.then(() => true)).catch(() => { });
|
|
279
330
|
}
|
|
280
331
|
/**
|
|
281
332
|
* Load the URL from a clean slate (storage cleared by the init script armed in
|
|
@@ -440,13 +491,39 @@ async function record(page, opts, newPath, depth, fp, st, sink, retryOnly = fals
|
|
|
440
491
|
const key = deriveKey(newPath, st.used);
|
|
441
492
|
const surface = { key, depth, path: newPath, elements: fp.elements };
|
|
442
493
|
st.surfaces.push(surface);
|
|
494
|
+
let addsVocab = false;
|
|
495
|
+
for (const c of fp.classes)
|
|
496
|
+
if (!st.classes.has(c)) {
|
|
497
|
+
st.classes.add(c);
|
|
498
|
+
addsVocab = true;
|
|
499
|
+
}
|
|
443
500
|
// Children buffer in the sweep's sink and enter the shared queue only when the
|
|
444
501
|
// parent's sweep completes — family retry reads the parent's changer registry,
|
|
445
502
|
// which is only complete then. (Serial mode passes st.queue directly.)
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
503
|
+
//
|
|
504
|
+
// COVERAGE-GUIDED QUEUE PRUNING: in --until-covered mode, a surface that adds
|
|
505
|
+
// NO new render vocabulary is a structural repeat (the ninth agent's dossier,
|
|
506
|
+
// identical to the first) — don't queue it for a sweep, because its subtree is
|
|
507
|
+
// the same repeats and contributes nothing to coverage. This is what turns the
|
|
508
|
+
// exhaustive breadth-first crawl into a FAST coverage check: the queue drains
|
|
509
|
+
// to the few distinct components, each still drilled to depth (its own new
|
|
510
|
+
// vocabulary keeps it queued) via now-reliable resets. Base (depth 0) always
|
|
511
|
+
// seeds. Exhaustive mode (default) queues everything, so the surface-by-surface
|
|
512
|
+
// diff still sees every state.
|
|
513
|
+
const keep = !opts.stopWhenCovered || addsVocab || depth === 0;
|
|
514
|
+
if (keep) {
|
|
515
|
+
sink.push({ path: newPath, depth, sig: fp.sig, retryOnly, viaRetry });
|
|
516
|
+
}
|
|
517
|
+
// Skip the expensive style-map capture (getComputedStyle over every element,
|
|
518
|
+
// per width) for coverage-redundant surfaces: in --until-covered mode a surface
|
|
519
|
+
// that adds no vocabulary needs no map — its classes were already counted from
|
|
520
|
+
// the fingerprint above — so paying the capture is pure waste. This is the
|
|
521
|
+
// difference between a fast coverage check and re-capturing hundreds of
|
|
522
|
+
// structural repeats. Distinct (vocab-adding) surfaces are still captured, so
|
|
523
|
+
// the run leaves a usable map set behind.
|
|
524
|
+
if (keep) {
|
|
525
|
+
await captureAndReport(page, opts, surface, st);
|
|
526
|
+
}
|
|
450
527
|
}
|
|
451
528
|
/** Capture the current page as `surface` at every width and report the outcome. */
|
|
452
529
|
async function captureAndReport(page, opts, surface, st) {
|
|
@@ -478,7 +555,7 @@ async function tryInPlace(page, c) {
|
|
|
478
555
|
}
|
|
479
556
|
/** Drive one candidate from where the page stands. Returns whether the page is
|
|
480
557
|
* still in the swept state (no-op click) and whether the action was a skip. */
|
|
481
|
-
async function driveCandidate(page, opts, entry, c, st, sink, viaRetry) {
|
|
558
|
+
async function driveCandidate(page, opts, entry, c, st, sink, viaRetry, presentIds = new Set()) {
|
|
482
559
|
// (retry-only lineage is inherited: a consumed state's descendants can also
|
|
483
560
|
// only be mode-switch views, never fresh-candidate exploration.)
|
|
484
561
|
const outcome = await tryInPlace(page, c);
|
|
@@ -509,7 +586,41 @@ async function driveCandidate(page, opts, entry, c, st, sink, viaRetry) {
|
|
|
509
586
|
reason: c.reason,
|
|
510
587
|
...(c.value ? { value: c.value } : {}),
|
|
511
588
|
};
|
|
512
|
-
|
|
589
|
+
const childPath = [...entry.path, step];
|
|
590
|
+
const childRetryOnly = entry.retryOnly || !persists;
|
|
591
|
+
await record(page, opts, childPath, entry.depth + 1, fp, st, sink, childRetryOnly, viaRetry);
|
|
592
|
+
// DESCEND IN PLACE. We are standing in the surface this click just opened,
|
|
593
|
+
// reached by a reliable forward click. Sweep its OWN fresh controls now, while
|
|
594
|
+
// we are here, rather than leaving it for the queued entry to re-reach by
|
|
595
|
+
// reset+replay. In EXHAUSTIVE mode this is the win: it maps a deep branch (an
|
|
596
|
+
// expanded run inside an expanded automation) while the page is already there,
|
|
597
|
+
// instead of via a fragile deep replay. The queued entry still executes for
|
|
598
|
+
// family-retry (pairwise) coverage; global `tried` means its fresh list is
|
|
599
|
+
// already empty here, so this does not double-drive.
|
|
600
|
+
//
|
|
601
|
+
// But descent is DEPTH-FIRST: it drills the first candidate's whole subtree
|
|
602
|
+
// before the base sweep reaches the next candidate, so a page whose distinct
|
|
603
|
+
// vocabulary lives across many components (a roster of dossiers) covers slowly.
|
|
604
|
+
// In --until-covered mode we therefore SKIP the in-place descent and let the
|
|
605
|
+
// breadth-first queue + coverage-guided pruning reach every component fast and
|
|
606
|
+
// drill each once — now that resets are reliable, the queue reaches depth too.
|
|
607
|
+
if (!opts.stopWhenCovered && persists && entry.depth + 1 < opts.maxDepth) {
|
|
608
|
+
const child = {
|
|
609
|
+
path: childPath,
|
|
610
|
+
depth: entry.depth + 1,
|
|
611
|
+
sig: fp.sig,
|
|
612
|
+
retryOnly: childRetryOnly,
|
|
613
|
+
viaRetry,
|
|
614
|
+
};
|
|
615
|
+
// Exclude controls that were ALSO present in the parent (mode-switchers,
|
|
616
|
+
// shared chrome): those are breadth, owned by the queued family-retry.
|
|
617
|
+
// Descend only into controls genuinely NEW to this surface — a run row that
|
|
618
|
+
// exists only after the job expanded — which is exactly the deep nested UI
|
|
619
|
+
// the reset-replay path starves.
|
|
620
|
+
await sweepCandidatesHere(page, opts, child, st, sink, /* freshOnly */ true, presentIds).catch(() => {
|
|
621
|
+
/* fail-soft: the surface was already captured; deeper descent is best-effort */
|
|
622
|
+
});
|
|
623
|
+
}
|
|
513
624
|
return { inState: false, skipped: false };
|
|
514
625
|
}
|
|
515
626
|
/**
|
|
@@ -546,19 +657,36 @@ function familyRetries(entry, all, st) {
|
|
|
546
657
|
* global chrome would otherwise starve a deep surface's own controls; the
|
|
547
658
|
* throttle applies to fresh ones), then the parent's persistent mode-switchers
|
|
548
659
|
* re-tried in THIS sibling mode. A state reached through a consuming action
|
|
549
|
-
* collects NO fresh candidates — see QueueEntry.retryOnly.
|
|
550
|
-
|
|
551
|
-
|
|
660
|
+
* collects NO fresh candidates — see QueueEntry.retryOnly.
|
|
661
|
+
*
|
|
662
|
+
* `freshOnly` drops the family-retries: the in-place descent of a
|
|
663
|
+
* freshly-opened surface explores its own new UI (DEPTH), but must NOT re-apply
|
|
664
|
+
* the parent's mode-switchers — that is the pairwise BREADTH, owned by the
|
|
665
|
+
* queued sweep. Re-applying them while descending would compound modes into
|
|
666
|
+
* N-way products (the very thing "retries don't compound" prevents). */
|
|
667
|
+
function sweepWorkList(entry, all, opts, st, freshOnly = false, excludeIds = new Set()) {
|
|
668
|
+
const fresh = entry.retryOnly
|
|
669
|
+
? []
|
|
670
|
+
: all.filter((c) => !st.tried.has(c.identity) && !excludeIds.has(c.identity)).slice(0, opts.maxActionsPerState);
|
|
552
671
|
return [
|
|
553
672
|
...fresh.map((c) => ({ c, retry: false })),
|
|
554
|
-
...familyRetries(entry, all, st).map((c) => ({ c, retry: true })),
|
|
673
|
+
...(freshOnly ? [] : familyRetries(entry, all, st).map((c) => ({ c, retry: true }))),
|
|
555
674
|
];
|
|
556
675
|
}
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
676
|
+
/** Drive one state's work list from where the page ALREADY stands (no reset). A
|
|
677
|
+
* no-op click leaves the page in the state and the loop continues; a
|
|
678
|
+
* state-changing click drives the child in place (see driveCandidate) and then
|
|
679
|
+
* a verified reset returns here before the next candidate. Split out from
|
|
680
|
+
* sweepState so driveCandidate can call it to DESCEND a freshly-opened surface
|
|
681
|
+
* in place — reaching that surface via a forward click is reliable, so its deep
|
|
682
|
+
* descendants are captured on the first visit instead of via a later reset. */
|
|
683
|
+
async function sweepCandidatesHere(page, opts, entry, st, sink, freshOnly = false, excludeIds = new Set()) {
|
|
560
684
|
const all = await page.evaluate(collectClickable).catch(() => []);
|
|
561
|
-
const work = sweepWorkList(entry, all, opts, st);
|
|
685
|
+
const work = sweepWorkList(entry, all, opts, st, freshOnly, excludeIds);
|
|
686
|
+
// Controls present HERE — passed to each child's in-place descent as its
|
|
687
|
+
// exclude set, so the descent skips this surface's mode-switchers/chrome and
|
|
688
|
+
// only drills genuinely new nested UI.
|
|
689
|
+
const presentIds = new Set(all.map((c) => c.identity));
|
|
562
690
|
let tried = 0;
|
|
563
691
|
let skipped = 0;
|
|
564
692
|
let inState = true;
|
|
@@ -577,13 +705,18 @@ async function sweepState(page, opts, entry, st, sink) {
|
|
|
577
705
|
continue;
|
|
578
706
|
}
|
|
579
707
|
tried++;
|
|
580
|
-
const r = await driveCandidate(page, opts, entry, c, st, sink, retry);
|
|
708
|
+
const r = await driveCandidate(page, opts, entry, c, st, sink, retry, presentIds);
|
|
581
709
|
inState = r.inState;
|
|
582
710
|
if (r.skipped)
|
|
583
711
|
skipped++;
|
|
584
712
|
}
|
|
585
713
|
return { tried, skipped };
|
|
586
714
|
}
|
|
715
|
+
async function sweepState(page, opts, entry, st, sink) {
|
|
716
|
+
if (!(await resetToState(page, opts, entry.path, entry.sig)))
|
|
717
|
+
return { tried: 0, skipped: 0 };
|
|
718
|
+
return sweepCandidatesHere(page, opts, entry, st, sink);
|
|
719
|
+
}
|
|
587
720
|
/** Capture one synthetic data state of the entry page (its data requests stalled
|
|
588
721
|
* or failed) in place, deduped and coverage-counted like any surface — but never
|
|
589
722
|
* queued: a stalled app is not a state to crawl deeper from. */
|
|
@@ -624,7 +757,7 @@ async function recordDataState(page, opts, mode, st) {
|
|
|
624
757
|
* crawl — dedup sets are shared and mutated synchronously — only dup-key
|
|
625
758
|
* suffix attribution can vary with timing.
|
|
626
759
|
*/
|
|
627
|
-
async function runPool(primary, opts, st, counters) {
|
|
760
|
+
async function runPool(primary, opts, st, counters, defined = []) {
|
|
628
761
|
const target = Math.max(1, opts.workers ?? 1);
|
|
629
762
|
const pages = [primary];
|
|
630
763
|
while (opts.newPage && pages.length < target) {
|
|
@@ -633,11 +766,39 @@ async function runPool(primary, opts, st, counters) {
|
|
|
633
766
|
await armResetStorage(extra);
|
|
634
767
|
pages.push(extra);
|
|
635
768
|
}
|
|
769
|
+
// Coverage-oriented early stop (opt-in): the moment every defined class has
|
|
770
|
+
// been SEEN, stop — the rest of the queue is redundant for a coverage check.
|
|
771
|
+
// We do NOT plateau-stop on "N surfaces without a new class": a productive
|
|
772
|
+
// deep state (an automation whose expandable run row is its last candidate)
|
|
773
|
+
// adds its vocabulary only after many no-new siblings, so a plateau cuts the
|
|
774
|
+
// crawl off before that state's sweep even starts. Termination instead comes
|
|
775
|
+
// from the queue draining — and it drains fast because coverage-guided pruning
|
|
776
|
+
// (see record) never queues a structural repeat. `cover.every` runs only when
|
|
777
|
+
// the class count actually grows, so this is cheap.
|
|
778
|
+
const cover = opts.stopWhenCovered && defined.length > 0 ? defined : null;
|
|
779
|
+
let prevSize = st.classes.size;
|
|
780
|
+
let covered = false;
|
|
781
|
+
const converged = () => {
|
|
782
|
+
if (!cover)
|
|
783
|
+
return false;
|
|
784
|
+
if (!covered && st.classes.size > prevSize) {
|
|
785
|
+
prevSize = st.classes.size;
|
|
786
|
+
if (cover.every((c) => st.classes.has(c)))
|
|
787
|
+
covered = true;
|
|
788
|
+
}
|
|
789
|
+
return covered;
|
|
790
|
+
};
|
|
636
791
|
let active = 0;
|
|
637
792
|
await new Promise((resolve) => {
|
|
638
793
|
const pump = () => {
|
|
639
|
-
while (pages.length > 0 && st.queue.length > 0 && st.surfaces.length < opts.maxStates) {
|
|
640
|
-
const entry = st.queue.
|
|
794
|
+
while (pages.length > 0 && st.queue.length > 0 && st.surfaces.length < opts.maxStates && !converged()) {
|
|
795
|
+
const entry = st.queue.shift(); // FIFO → breadth-first: exhaust every
|
|
796
|
+
// shallow surface (nav tabs, opened panels — where distinct UI lives)
|
|
797
|
+
// before drilling. Depth-first starves breadth: one append-generator
|
|
798
|
+
// branch drills to depth 20+ while sibling tabs sit unpopped, so real
|
|
799
|
+
// surfaces (an OAuth card, a skills grid) go uncaptured. Dedup is
|
|
800
|
+
// set-based, so order never changes WHAT is found — only that shallow
|
|
801
|
+
// is found first, which is what full coverage needs.
|
|
641
802
|
if (entry.depth >= opts.maxDepth)
|
|
642
803
|
continue;
|
|
643
804
|
const worker = pages.pop();
|
|
@@ -658,7 +819,7 @@ async function runPool(primary, opts, st, counters) {
|
|
|
658
819
|
pump();
|
|
659
820
|
});
|
|
660
821
|
}
|
|
661
|
-
if (active === 0 && (st.queue.length === 0 || st.surfaces.length >= opts.maxStates))
|
|
822
|
+
if (active === 0 && (st.queue.length === 0 || st.surfaces.length >= opts.maxStates || converged()))
|
|
662
823
|
resolve();
|
|
663
824
|
};
|
|
664
825
|
pump();
|
|
@@ -712,7 +873,7 @@ async function discover(page, opts) {
|
|
|
712
873
|
await recordDataState(page, opts, 'error', st);
|
|
713
874
|
}
|
|
714
875
|
const counters = { tried: 0, skipped: 0 };
|
|
715
|
-
await runPool(page, opts, st, counters);
|
|
876
|
+
await runPool(page, opts, st, counters, defined);
|
|
716
877
|
const missing = defined.filter((c) => !st.classes.has(c)).sort();
|
|
717
878
|
return {
|
|
718
879
|
surfaces: st.surfaces,
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.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",
|