styleproof 3.5.0 → 3.6.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 CHANGED
@@ -7,6 +7,41 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.6.0] - 2026-07-03
11
+
12
+ ### Changed
13
+
14
+ - Crawl candidate collection collapses inherited-cursor subtrees to their
15
+ OUTERMOST clickable. `cursor` is an inherited CSS property, so a clickable card
16
+ makes every descendant compute `cursor: pointer`; each became its own candidate
17
+ even though clicking any of them just bubbles to the card's handler (the same
18
+ surface). Each redundant candidate paid a drive + verified reset — the dominant
19
+ cost of a large crawl. Measured on a 241-class design: base candidates 557 → 22,
20
+ whole coverage crawl ~40min → ~6min (240/241, unchanged). Semantic controls
21
+ nested in a clickable container are still kept.
22
+ - `--until-covered` reaches deep coverage: it now terminates on the queue
23
+ draining (or full coverage), not a fixed "N surfaces without a new class"
24
+ plateau — a plateau cut the crawl off before a productive deep state (an
25
+ automation whose expandable run row is its last candidate) was ever swept.
26
+ Coverage mode also (a) prunes the queue to surfaces that add new render
27
+ vocabulary — a structural repeat like the ninth agent's identical dossier is
28
+ captured once but not re-drilled — and (b) uses the breadth-first queue rather
29
+ than the depth-first in-place descent, so distinct components are reached fast
30
+ and each drilled once via now-reliable resets. Measured on a 241-class design:
31
+ 240/241 (the one gap a post-decision state reachable only with a setup step),
32
+ where a plateau-stopped crawl left the deep automation run-detail unmapped.
33
+
34
+ ### Fixed
35
+
36
+ - Reset-replay to depth >= 2 no longer fails: the crawler's structural
37
+ fingerprint counted StyleProof's OWN injected hover-sink `<div>` (added during
38
+ a capture, so present when a state is captured in place but absent on a fresh
39
+ reset+replay). Every such reset failed its fingerprint verification, so any
40
+ surface reachable ONLY by re-driving from a deep state — a pairwise mode
41
+ combination like a tab's edit view — was silently lost. The fingerprint now
42
+ excludes the sink (and framework route-announcers). Measured: depth-2..5
43
+ resets went from all-fail to all-pass.
44
+
10
45
  ### Fixed
11
46
 
12
47
  - Report tables never show an equal-looking Before/After pair for a real diff.
@@ -20,23 +55,51 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
20
55
 
21
56
  ### Changed
22
57
 
23
- - Control identity is now SEMANTIC, not positional: the driven-once dedup keys
24
- on tag ancestry (no indices, no classes) + label + role — what stays stable
25
- across every re-render instead of nth-of-type selectors. Mode switches
26
- re-render subtrees and re-mint positional selectors for the same logical
27
- controls, which made every combination view refill the fresh-candidate pool
28
- (a crawl inflated past 2,500 surfaces through that leak). Positional clones
29
- with identical tag-path AND label (a repeated row's expand button) merge —
30
- one is explored; distinctly-labeled controls stay distinct; anything hidden
31
- behind a merge is still NAMED by the coverage verifier.
32
- - Family retries no longer compound: a state reached via a retry still explores
33
- its genuinely-new UI, but never re-retries mode-switchers. Every PAIRWISE
34
- combination of independent modes is captured (a tab's edit state, a decided
35
- list's other tab); 3-way-and-deeper toggle products which multiply surfaces
36
- without new render vocabulary are not walked. Observed live: an exhaustive
37
- crawl inflated past 725 surfaces in the N-way product tail; the pairwise walk
38
- covers the same vocabulary in a fraction of the states. Anything class-visible
39
- only at deeper combination depth is still NAMED by the coverage verifier.
58
+ - Crawl reset settle no longer waits on `networkidle`: it polls DOM-growth to
59
+ detect the mounted app (unchanged) and then waits for `document.fonts.ready`
60
+ specifically (fonts are part of the computed style the diff compares, so they
61
+ must be loaded but that is the deterministic signal, cache-warm on repeat
62
+ loads). networkidle waited a 500ms idle window ON TOP of a cross-origin font
63
+ sheet that lingered ~1s per load with no bearing on readiness; since every
64
+ state reset re-navigates, that dominated crawl time. Measured: settle 911ms
65
+ 105ms per reset (~8.7x), whole-crawl surface rate ~5/min ~17/min.
66
+
67
+ - Crawl is now breadth-first (was depth-first): every shallow surface nav
68
+ tabs, opened panels, the tabs inside a dossier — is exhausted before drilling
69
+ deeper. Depth-first starved breadth: one append-generator branch drilled past
70
+ depth 20 while sibling tabs sat unvisited, so real surfaces (an OAuth card, a
71
+ skills grid, a markdown editor) went uncaptured measured live as 48 defined
72
+ classes never rendered across ~12 surfaces. Dedup is set-based, so order
73
+ changes only WHICH surface is found first, never the final set.
74
+ - `maxDepth` default 1000 -> 16: exhaustive for real UI (nothing human-navigable
75
+ is 16 clicks from load) while terminating append-generator chains, whose every
76
+ appended node is a fresh tag-path identity. The coverage verifier still names
77
+ any class left unrendered, so a too-low cap fails loudly rather than lying.
78
+
79
+ - The never-click guard now also covers state-mutating verbs (rotate,
80
+ provision, seal, regenerate, renew): mapping must not mutate, and a mutating
81
+ control that persists after its click re-labels its surroundings with fresh
82
+ data on every press — an unbounded mutation farm a crawl must not walk.
83
+ Observed live: credential-rotation cells minted new identities per press,
84
+ inflating a crawl past 470 surfaces at depth 23. Their render states are
85
+ seed-data territory (and anything unreached is named by the verifier).
86
+ - Freshly-opened surfaces are swept IN PLACE (forward-drive), not only queued
87
+ for a later reset+replay. Reaching a surface by a forward click is reliable;
88
+ re-reaching it by reset is slow (a reset per candidate) and, at depth, starved
89
+ — a dossier's many filter combinations flood the queue and bury the deep
90
+ sweep, so an expanded run row inside an expanded job was captured 0 times
91
+ despite being reachable (now 29). The descent drives only fresh controls new
92
+ to the surface (excludes parent-present mode-switchers), so pairwise mode
93
+ coverage is unchanged and the lattice stays pairwise.
94
+
95
+ ### Added
96
+
97
+ - `--until-covered`: stop the crawl as soon as every class the page's
98
+ stylesheets define has rendered (full coverage) or coverage stops improving
99
+ (no new class for a plateau of surfaces). Turns an exhaustive crawl into a
100
+ fast coverage check that stops once it has SEEN everything, instead of
101
+ enumerating every combinatorial surface that adds no new vocabulary. Opt-in;
102
+ exhaustive remains the default.
40
103
 
41
104
  ## [3.5.0] - 2026-07-02
42
105
 
@@ -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(),
@@ -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;
@@ -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,
@@ -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. */
@@ -7,7 +7,14 @@ import { detectViewportWidths } from './breakpoints.js';
7
7
  export const CRAWL_DEFAULTS = {
8
8
  height: 900,
9
9
  screenshots: true,
10
- maxDepth: 1000,
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 === 'pointer' || cursor === 'grab')
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
- await page.waitForLoadState('networkidle').catch(() => { });
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
- return;
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
- sink.push({ path: newPath, depth, sig: fp.sig, retryOnly, viaRetry });
447
- for (const c of fp.classes)
448
- st.classes.add(c);
449
- await captureAndReport(page, opts, surface, st);
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
- await record(page, opts, [...entry.path, step], entry.depth + 1, fp, st, sink, entry.retryOnly || !persists, viaRetry);
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
- function sweepWorkList(entry, all, opts, st) {
551
- const fresh = entry.retryOnly ? [] : all.filter((c) => !st.tried.has(c.identity)).slice(0, opts.maxActionsPerState);
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
- async function sweepState(page, opts, entry, st, sink) {
558
- if (!(await resetToState(page, opts, entry.path, entry.sig)))
559
- return { tried: 0, skipped: 0 };
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.pop(); // LIFOdepth-first
794
+ while (pages.length > 0 && st.queue.length > 0 && st.surfaces.length < opts.maxStates && !converged()) {
795
+ const entry = st.queue.shift(); // FIFObreadth-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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.5.0",
3
+ "version": "3.6.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",