styleproof 3.4.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 +106 -0
- package/README.md +1 -1
- package/bin/styleproof-capture.mjs +6 -0
- package/dist/capture-url.d.ts +7 -0
- package/dist/capture-url.js +6 -0
- package/dist/crawl-surfaces.d.ts +18 -5
- package/dist/crawl-surfaces.js +293 -52
- package/dist/describe.js +4 -1
- package/dist/report.d.ts +1 -0
- package/dist/report.js +45 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,112 @@ 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
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- Report tables never show an equal-looking Before/After pair for a real diff.
|
|
48
|
+
A colour embedded in a compound value (gradient, shadow) no longer stands in
|
|
49
|
+
for the whole value — `toHex` only converts values that ARE a colour — and
|
|
50
|
+
long value pairs (gradients, data URIs) are excerpted around the differing
|
|
51
|
+
substring with a little shared context on each side.
|
|
52
|
+
- Report values render verbatim: display rounding of decimals is gone (alpha
|
|
53
|
+
`0.18` was shown as `0.2`, and a real `0.18 → 0.2` change could be dropped
|
|
54
|
+
as a no-op after rounding).
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
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.
|
|
103
|
+
|
|
104
|
+
## [3.5.0] - 2026-07-02
|
|
105
|
+
|
|
106
|
+
### Added
|
|
107
|
+
|
|
108
|
+
- Parallel crawl: `--workers <n>` (default 4) sweeps queued states concurrently,
|
|
109
|
+
each worker on its own browser context. The surface set is identical to a
|
|
110
|
+
serial crawl — dedup sets are shared, and a state's children only join the
|
|
111
|
+
queue when its sweep completes, so family retry never reads a half-built
|
|
112
|
+
changer registry. Only dup-key suffix attribution can vary with timing; pass
|
|
113
|
+
`--workers 1` for byte-stable keys. Exhaustive runs drop from hours to
|
|
114
|
+
minutes on lattice-heavy designs.
|
|
115
|
+
|
|
10
116
|
## [3.4.0] - 2026-07-02
|
|
11
117
|
|
|
12
118
|
### Fixed
|
package/README.md
CHANGED
|
@@ -424,7 +424,7 @@ styleproof-diff design .styleproof/maps/current # diff the whole
|
|
|
424
424
|
|
|
425
425
|
It's **exhaustive by default**: the crawl stops when there is nothing left to drive — every control tried once, every structurally new surface captured — not at a budget. Termination is guaranteed by dedup (controls dedup by selector, surfaces by a structural fingerprint), and the `--max-depth` / `--max-actions` / `--max-states` flags exist only as deliberate throttles. It's deterministic (document order; the same surface reached two ways is captured once) and self-settling — it waits for an async app (React/Vue/Babel that boots after `load`) to mount before reading, so a bare crawl of a client-rendered page still captures the mounted UI.
|
|
426
426
|
|
|
427
|
-
What makes exhaustive affordable is that the sweep works **in place**: standing in a state, each control is clicked right where the page is, and a cheap DOM fingerprint decides what happened — a no-op click costs nothing, and only a state-changing click pays a reset (fresh navigation + replay of the click-path), which is then **verified by fingerprint** so children are never attributed to the wrong parent. New surfaces are captured at every width the moment they're reached — a deep or animated click-path is never re-driven to capture, so it can't be the thing that drops a surface. Progress streams as it goes, one line per captured surface.
|
|
427
|
+
What makes exhaustive affordable is that the sweep works **in place**: standing in a state, each control is clicked right where the page is, and a cheap DOM fingerprint decides what happened — a no-op click costs nothing, and only a state-changing click pays a reset (fresh navigation + replay of the click-path), which is then **verified by fingerprint** so children are never attributed to the wrong parent. New surfaces are captured at every width the moment they're reached — a deep or animated click-path is never re-driven to capture, so it can't be the thing that drops a surface. Progress streams as it goes, one line per captured surface. And it's **parallel by default** — `--workers <n>` (default 4) sweeps states concurrently on isolated browser contexts with the exact same surface set as a serial crawl (dedup is shared; children only enter the queue when their parent's sweep completes); `--workers 1` if you want byte-stable dup-key attribution.
|
|
428
428
|
|
|
429
429
|
**And it proves nothing was missed.** After the crawl, StyleProof compares every class the page's own stylesheets define (read from the parsed CSSOM) against the classes actually rendered across the captured surfaces, and prints what — if anything — was never seen. `--require-full-coverage` turns any residue into exit code 4, so "the design is fully covered" is a CI-checkable property, not a judgement call. What's left is either dead CSS (delete it) or a state the crawl couldn't reach (drive it with a spec, or file the gap).
|
|
430
430
|
|
|
@@ -51,6 +51,8 @@ whole surface: --crawl
|
|
|
51
51
|
--no-data-states skip the automatic loading/error captures of the entry page
|
|
52
52
|
(on by default: data requests stalled → loading skeleton;
|
|
53
53
|
fulfilled with 500 → error render)
|
|
54
|
+
--workers <n> concurrent sweep workers (default 4); same surface set as a
|
|
55
|
+
serial crawl — pass 1 for byte-stable key attribution
|
|
54
56
|
--max-depth <n> throttle recursion depth (default: unbounded)
|
|
55
57
|
--max-actions <n> throttle controls tried per state (default: unbounded)
|
|
56
58
|
--max-states <n> throttle total surfaces (default: unbounded)
|
|
@@ -105,6 +107,10 @@ async function runCrawl() {
|
|
|
105
107
|
resetStorage: opts.resetStorage,
|
|
106
108
|
setup: setupSteps,
|
|
107
109
|
dataStates: opts.dataStates,
|
|
110
|
+
stopWhenCovered: opts.untilCovered,
|
|
111
|
+
workers: opts.workers,
|
|
112
|
+
// each worker page in its OWN context, so storage resets can't interfere
|
|
113
|
+
newPage: async () => (await browser.newContext()).newPage(),
|
|
108
114
|
// Stream each surface as it is captured, so progress is visible live and an
|
|
109
115
|
// interrupted run still shows exactly what it mapped.
|
|
110
116
|
onSurface: (s, ok) =>
|
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;
|
|
@@ -65,6 +70,8 @@ export type CaptureUrlOptions = {
|
|
|
65
70
|
/** crawl: also capture automatic `loading`/`error` data states of the entry
|
|
66
71
|
* page (default true). */
|
|
67
72
|
dataStates: boolean;
|
|
73
|
+
/** crawl: concurrent sweep workers (default 4). 1 = byte-stable key attribution. */
|
|
74
|
+
workers: number;
|
|
68
75
|
};
|
|
69
76
|
/**
|
|
70
77
|
* Parse `styleproof-capture` argv into options. Pure and throwing so the CLI
|
package/dist/capture-url.js
CHANGED
|
@@ -31,7 +31,9 @@ const DEFAULTS = {
|
|
|
31
31
|
maxStates: 100000,
|
|
32
32
|
resetStorage: true,
|
|
33
33
|
requireFullCoverage: false,
|
|
34
|
+
untilCovered: false,
|
|
34
35
|
dataStates: true,
|
|
36
|
+
workers: 4,
|
|
35
37
|
};
|
|
36
38
|
function positiveNumber(raw, flag) {
|
|
37
39
|
const n = Number(raw);
|
|
@@ -62,6 +64,7 @@ const VALUE_FLAGS = {
|
|
|
62
64
|
'--max-actions': (o, v) => (o.maxActionsPerState = positiveNumber(v, '--max-actions')),
|
|
63
65
|
'--max-states': (o, v) => (o.maxStates = positiveNumber(v, '--max-states')),
|
|
64
66
|
'--setup': (o, v) => (o.setupFile = v),
|
|
67
|
+
'--workers': (o, v) => (o.workers = positiveNumber(v, '--workers')),
|
|
65
68
|
};
|
|
66
69
|
const BOOL_FLAGS = {
|
|
67
70
|
'--screenshots': (o) => (o.screenshots = true),
|
|
@@ -69,6 +72,7 @@ const BOOL_FLAGS = {
|
|
|
69
72
|
'--crawl': (o) => (o.crawl = true),
|
|
70
73
|
'--no-reset-storage': (o) => (o.resetStorage = false),
|
|
71
74
|
'--require-full-coverage': (o) => (o.requireFullCoverage = true),
|
|
75
|
+
'--until-covered': (o) => (o.untilCovered = true),
|
|
72
76
|
'--data-states': (o) => (o.dataStates = true),
|
|
73
77
|
'--no-data-states': (o) => (o.dataStates = false),
|
|
74
78
|
};
|
|
@@ -117,8 +121,10 @@ export function parseCaptureUrlArgs(argv) {
|
|
|
117
121
|
maxStates: DEFAULTS.maxStates,
|
|
118
122
|
resetStorage: DEFAULTS.resetStorage,
|
|
119
123
|
requireFullCoverage: DEFAULTS.requireFullCoverage,
|
|
124
|
+
untilCovered: DEFAULTS.untilCovered,
|
|
120
125
|
setupFile: undefined,
|
|
121
126
|
dataStates: DEFAULTS.dataStates,
|
|
127
|
+
workers: DEFAULTS.workers,
|
|
122
128
|
};
|
|
123
129
|
const positional = [];
|
|
124
130
|
for (let i = 0; i < argv.length; i++)
|
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. */
|
|
@@ -101,6 +109,15 @@ export type SurfaceCrawlOptions = {
|
|
|
101
109
|
/** Called as each surface is recorded (captured=false when its full capture failed).
|
|
102
110
|
* Lets CLIs stream progress instead of reporting only at the end. */
|
|
103
111
|
onSurface?: (surface: CrawledSurface, captured: boolean) => void;
|
|
112
|
+
/** Concurrent sweep workers (default 4). Each worker gets its own page from
|
|
113
|
+
* `newPage`; without a factory the crawl runs single-page regardless. The
|
|
114
|
+
* surface SET is identical to a serial crawl (same dedup sets); only which
|
|
115
|
+
* path first claims a shape — and so dup-key suffixes — can vary run to run.
|
|
116
|
+
* Use workers: 1 for byte-stable key attribution. */
|
|
117
|
+
workers?: number;
|
|
118
|
+
/** Factory for worker pages — create each in its OWN browser context so
|
|
119
|
+
* storage resets cannot interfere across concurrent sweeps. */
|
|
120
|
+
newPage?: () => Promise<Page>;
|
|
104
121
|
};
|
|
105
122
|
export declare const CRAWL_DEFAULTS: {
|
|
106
123
|
height: number;
|
|
@@ -109,14 +126,10 @@ export declare const CRAWL_DEFAULTS: {
|
|
|
109
126
|
maxActionsPerState: number;
|
|
110
127
|
maxStates: number;
|
|
111
128
|
resetStorage: boolean;
|
|
129
|
+
workers: number;
|
|
112
130
|
};
|
|
113
131
|
/** Run the caller's deterministic setup steps (login, unlock, seed input). A
|
|
114
132
|
* non-optional step that fails throws loudly — a half-established gate must
|
|
115
133
|
* never silently crawl the ungated page instead. */
|
|
116
134
|
export declare function runSetup(page: Page, steps: SetupStep[]): Promise<void>;
|
|
117
|
-
/**
|
|
118
|
-
* Crawl `opts.url` and capture every reachable surface at every width — runs to
|
|
119
|
-
* natural termination by default. Returns the surfaces mapped (with the click-path
|
|
120
|
-
* that reached each), how many actions were tried/skipped, and captured/failed.
|
|
121
|
-
*/
|
|
122
135
|
export declare function crawlAndCapture(page: Page, opts: SurfaceCrawlOptions): Promise<CrawlReport>;
|
package/dist/crawl-surfaces.js
CHANGED
|
@@ -7,10 +7,18 @@ 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,
|
|
21
|
+
workers: 4,
|
|
14
22
|
};
|
|
15
23
|
function slug(value) {
|
|
16
24
|
return (value
|
|
@@ -40,7 +48,7 @@ function deriveKey(steps, used) {
|
|
|
40
48
|
/* c8 ignore start */ // fallow-ignore-next-line complexity
|
|
41
49
|
function collectClickable() {
|
|
42
50
|
const SEMANTIC = 'button,summary,[role="button"],[role="tab"],[role="menuitem"],[role="combobox"],select,form';
|
|
43
|
-
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;
|
|
44
52
|
const esc = (v) => CSS.escape(v);
|
|
45
53
|
const quote = (v) => JSON.stringify(v);
|
|
46
54
|
const visible = (el) => {
|
|
@@ -77,6 +85,19 @@ function collectClickable() {
|
|
|
77
85
|
.replace(/\s+/g, ' ')
|
|
78
86
|
.trim()
|
|
79
87
|
.slice(0, 80) || el.tagName.toLowerCase();
|
|
88
|
+
const identityFor = (el) => {
|
|
89
|
+
// Tag-path only — NO classes and NO indices: classes carry state (body.alt,
|
|
90
|
+
// .on) and would re-contextualize the same control per mode; indices carry
|
|
91
|
+
// position and drift on re-render. Tag ancestry + label is what stays
|
|
92
|
+
// stable across every context the same logical control appears in.
|
|
93
|
+
const parts = [];
|
|
94
|
+
let cur = el;
|
|
95
|
+
while (cur && cur !== document.documentElement) {
|
|
96
|
+
parts.unshift(cur.tagName.toLowerCase());
|
|
97
|
+
cur = cur.parentElement;
|
|
98
|
+
}
|
|
99
|
+
return `${parts.join('>')}|${labelFor(el)}|${el.getAttribute('role') ?? ''}`;
|
|
100
|
+
};
|
|
80
101
|
// Semantic controls first (stable, meaningful), then anything styled clickable.
|
|
81
102
|
// `grab` counts too: a draggable card is routinely ALSO a click target (open on
|
|
82
103
|
// click, drag to move), and we never drag — el.click() fires no drag gesture.
|
|
@@ -85,7 +106,28 @@ function collectClickable() {
|
|
|
85
106
|
if (pool.has(el))
|
|
86
107
|
continue;
|
|
87
108
|
const cursor = getComputedStyle(el).cursor;
|
|
88
|
-
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)
|
|
89
131
|
pool.add(el);
|
|
90
132
|
}
|
|
91
133
|
// Neutral text inputs are typed automatically with a deterministic value —
|
|
@@ -117,6 +159,7 @@ function collectClickable() {
|
|
|
117
159
|
out.push({
|
|
118
160
|
action: 'fill-input',
|
|
119
161
|
selector,
|
|
162
|
+
identity: identityFor(el),
|
|
120
163
|
label: labelFor(el) === el.tagName.toLowerCase() ? (el.getAttribute('placeholder') ?? 'input') : labelFor(el),
|
|
121
164
|
reason: 'auto-fill',
|
|
122
165
|
value: AUTO_VALUE[kind] ?? 'sample text',
|
|
@@ -139,12 +182,21 @@ function collectClickable() {
|
|
|
139
182
|
if (el instanceof HTMLSelectElement) {
|
|
140
183
|
const next = [...el.options].find((o) => !o.disabled && o.value !== el.value);
|
|
141
184
|
if (next)
|
|
142
|
-
out.push({
|
|
185
|
+
out.push({
|
|
186
|
+
action: 'select-option',
|
|
187
|
+
selector,
|
|
188
|
+
identity: identityFor(el),
|
|
189
|
+
label,
|
|
190
|
+
reason: 'select-option',
|
|
191
|
+
value: next.value,
|
|
192
|
+
unsafe,
|
|
193
|
+
});
|
|
143
194
|
}
|
|
144
195
|
else {
|
|
145
196
|
out.push({
|
|
146
197
|
action: 'click',
|
|
147
198
|
selector,
|
|
199
|
+
identity: identityFor(el),
|
|
148
200
|
label,
|
|
149
201
|
reason: el.getAttribute('role') === 'tab' ? 'tab' : 'click',
|
|
150
202
|
unsafe,
|
|
@@ -179,6 +231,17 @@ function domShape() {
|
|
|
179
231
|
for (const el of document.body.getElementsByTagName('*')) {
|
|
180
232
|
if (SKIP.has(el.tagName))
|
|
181
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;
|
|
182
245
|
add(el);
|
|
183
246
|
}
|
|
184
247
|
return { shape: parts.join('\n'), elements: parts.length, classes: [...classes] };
|
|
@@ -243,15 +306,27 @@ async function settleDom(page, maxMs = 1200) {
|
|
|
243
306
|
* non-trivial. Generic — no app-specific selector needed, so a bare crawl of a
|
|
244
307
|
* Babel/React/Vue page that boots after `load` still captures the mounted UI. */
|
|
245
308
|
async function waitSettled(page) {
|
|
246
|
-
|
|
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).
|
|
247
321
|
let prev = -1;
|
|
248
322
|
for (let i = 0; i < 40; i++) {
|
|
249
323
|
const n = await page.evaluate(() => document.body.getElementsByTagName('*').length);
|
|
250
324
|
if (n > 5 && n === prev)
|
|
251
|
-
|
|
325
|
+
break;
|
|
252
326
|
prev = n;
|
|
253
327
|
await page.waitForTimeout(100);
|
|
254
328
|
}
|
|
329
|
+
await page.evaluate(() => document.fonts.ready.then(() => true)).catch(() => { });
|
|
255
330
|
}
|
|
256
331
|
/**
|
|
257
332
|
* Load the URL from a clean slate (storage cleared by the init script armed in
|
|
@@ -412,14 +487,43 @@ function stateKey(steps) {
|
|
|
412
487
|
}
|
|
413
488
|
/** Record a newly-found surface, capture it in place (page is already there), and
|
|
414
489
|
* queue it for its own sweep. Streams progress via onSurface. */
|
|
415
|
-
async function record(page, opts, newPath, depth, fp, st, retryOnly = false) {
|
|
490
|
+
async function record(page, opts, newPath, depth, fp, st, sink, retryOnly = false, viaRetry = false) {
|
|
416
491
|
const key = deriveKey(newPath, st.used);
|
|
417
492
|
const surface = { key, depth, path: newPath, elements: fp.elements };
|
|
418
493
|
st.surfaces.push(surface);
|
|
419
|
-
|
|
494
|
+
let addsVocab = false;
|
|
420
495
|
for (const c of fp.classes)
|
|
421
|
-
st.classes.
|
|
422
|
-
|
|
496
|
+
if (!st.classes.has(c)) {
|
|
497
|
+
st.classes.add(c);
|
|
498
|
+
addsVocab = true;
|
|
499
|
+
}
|
|
500
|
+
// Children buffer in the sweep's sink and enter the shared queue only when the
|
|
501
|
+
// parent's sweep completes — family retry reads the parent's changer registry,
|
|
502
|
+
// which is only complete then. (Serial mode passes st.queue directly.)
|
|
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
|
+
}
|
|
423
527
|
}
|
|
424
528
|
/** Capture the current page as `surface` at every width and report the outcome. */
|
|
425
529
|
async function captureAndReport(page, opts, surface, st) {
|
|
@@ -451,7 +555,7 @@ async function tryInPlace(page, c) {
|
|
|
451
555
|
}
|
|
452
556
|
/** Drive one candidate from where the page stands. Returns whether the page is
|
|
453
557
|
* still in the swept state (no-op click) and whether the action was a skip. */
|
|
454
|
-
async function driveCandidate(page, opts, entry, c, st) {
|
|
558
|
+
async function driveCandidate(page, opts, entry, c, st, sink, viaRetry, presentIds = new Set()) {
|
|
455
559
|
// (retry-only lineage is inherited: a consumed state's descendants can also
|
|
456
560
|
// only be mode-switch views, never fresh-candidate exploration.)
|
|
457
561
|
const outcome = await tryInPlace(page, c);
|
|
@@ -468,7 +572,7 @@ async function driveCandidate(page, opts, entry, c, st) {
|
|
|
468
572
|
.catch(() => false);
|
|
469
573
|
const from = stateKey(entry.path);
|
|
470
574
|
const list = st.changersFrom.get(from) ?? [];
|
|
471
|
-
if (!list.some((x) => x.c.
|
|
575
|
+
if (!list.some((x) => x.c.identity === c.identity))
|
|
472
576
|
list.push({ c, persists });
|
|
473
577
|
st.changersFrom.set(from, list);
|
|
474
578
|
const fp = await fingerprint(page);
|
|
@@ -482,7 +586,41 @@ async function driveCandidate(page, opts, entry, c, st) {
|
|
|
482
586
|
reason: c.reason,
|
|
483
587
|
...(c.value ? { value: c.value } : {}),
|
|
484
588
|
};
|
|
485
|
-
|
|
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
|
+
}
|
|
486
624
|
return { inState: false, skipped: false };
|
|
487
625
|
}
|
|
488
626
|
/**
|
|
@@ -494,32 +632,61 @@ async function driveCandidate(page, opts, entry, c, st) {
|
|
|
494
632
|
/** The family-retry list for a state: its parent's persistent mode-switchers that
|
|
495
633
|
* are visible right now — minus the step that created this state itself. */
|
|
496
634
|
function familyRetries(entry, all, st) {
|
|
635
|
+
// RETRIES DO NOT COMPOUND: a state reached via a family retry still explores
|
|
636
|
+
// its genuinely-new UI (fresh selectors), but never re-retries mode-switchers.
|
|
637
|
+
// Every PAIRWISE mode combination is captured; N-way products of independent
|
|
638
|
+
// toggles are not walked — they multiply states without new render vocabulary,
|
|
639
|
+
// and anything class-visible only at 3-way depth is still NAMED by the
|
|
640
|
+
// coverage verifier.
|
|
641
|
+
if (entry.viaRetry)
|
|
642
|
+
return [];
|
|
497
643
|
if (entry.path.length === 0)
|
|
498
644
|
return [];
|
|
499
645
|
const parentKey = stateKey(entry.path.slice(0, -1));
|
|
500
646
|
const ownSelector = entry.path[entry.path.length - 1].selector;
|
|
501
|
-
|
|
647
|
+
// Match registered changers to THIS state's candidates by semantic identity —
|
|
648
|
+
// the mode switch re-rendered the subtree, so positional selectors drifted;
|
|
649
|
+
// the current candidate carries the right selector for this state.
|
|
650
|
+
const byIdentity = new Map(all.map((c) => [c.identity, c]));
|
|
502
651
|
return (st.changersFrom.get(parentKey) ?? [])
|
|
503
|
-
.filter((x) => x.persists && x.c.selector !== ownSelector
|
|
504
|
-
.map((x) => x.c)
|
|
652
|
+
.filter((x) => x.persists && x.c.selector !== ownSelector)
|
|
653
|
+
.map((x) => byIdentity.get(x.c.identity))
|
|
654
|
+
.filter((c) => Boolean(c));
|
|
505
655
|
}
|
|
506
656
|
/** The work list for one state's sweep: fresh controls first (already-driven
|
|
507
657
|
* global chrome would otherwise starve a deep surface's own controls; the
|
|
508
658
|
* throttle applies to fresh ones), then the parent's persistent mode-switchers
|
|
509
659
|
* re-tried in THIS sibling mode. A state reached through a consuming action
|
|
510
|
-
* collects NO fresh candidates — see QueueEntry.retryOnly.
|
|
511
|
-
|
|
512
|
-
|
|
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);
|
|
513
671
|
return [
|
|
514
672
|
...fresh.map((c) => ({ c, retry: false })),
|
|
515
|
-
...familyRetries(entry, all, st).map((c) => ({ c, retry: true })),
|
|
673
|
+
...(freshOnly ? [] : familyRetries(entry, all, st).map((c) => ({ c, retry: true }))),
|
|
516
674
|
];
|
|
517
675
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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()) {
|
|
521
684
|
const all = await page.evaluate(collectClickable).catch(() => []);
|
|
522
|
-
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));
|
|
523
690
|
let tried = 0;
|
|
524
691
|
let skipped = 0;
|
|
525
692
|
let inState = true;
|
|
@@ -532,19 +699,24 @@ async function sweepState(page, opts, entry, st) {
|
|
|
532
699
|
inState = true;
|
|
533
700
|
}
|
|
534
701
|
if (!retry)
|
|
535
|
-
st.tried.add(c.
|
|
702
|
+
st.tried.add(c.identity);
|
|
536
703
|
if (c.unsafe) {
|
|
537
704
|
skipped++;
|
|
538
705
|
continue;
|
|
539
706
|
}
|
|
540
707
|
tried++;
|
|
541
|
-
const r = await driveCandidate(page, opts, entry, c, st);
|
|
708
|
+
const r = await driveCandidate(page, opts, entry, c, st, sink, retry, presentIds);
|
|
542
709
|
inState = r.inState;
|
|
543
710
|
if (r.skipped)
|
|
544
711
|
skipped++;
|
|
545
712
|
}
|
|
546
713
|
return { tried, skipped };
|
|
547
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
|
+
}
|
|
548
720
|
/** Capture one synthetic data state of the entry page (its data requests stalled
|
|
549
721
|
* or failed) in place, deduped and coverage-counted like any surface — but never
|
|
550
722
|
* queued: a stalled app is not a state to crawl deeper from. */
|
|
@@ -578,6 +750,81 @@ async function recordDataState(page, opts, mode, st) {
|
|
|
578
750
|
await page.unroute('**/*');
|
|
579
751
|
}
|
|
580
752
|
}
|
|
753
|
+
/**
|
|
754
|
+
* Sweep the queue with N concurrent workers, each on its own page. LIFO keeps
|
|
755
|
+
* the depth-first bias; a worker's discovered children enter the shared queue
|
|
756
|
+
* only when its sweep completes (see record). The surface SET matches a serial
|
|
757
|
+
* crawl — dedup sets are shared and mutated synchronously — only dup-key
|
|
758
|
+
* suffix attribution can vary with timing.
|
|
759
|
+
*/
|
|
760
|
+
async function runPool(primary, opts, st, counters, defined = []) {
|
|
761
|
+
const target = Math.max(1, opts.workers ?? 1);
|
|
762
|
+
const pages = [primary];
|
|
763
|
+
while (opts.newPage && pages.length < target) {
|
|
764
|
+
const extra = await opts.newPage();
|
|
765
|
+
if (opts.resetStorage)
|
|
766
|
+
await armResetStorage(extra);
|
|
767
|
+
pages.push(extra);
|
|
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
|
+
};
|
|
791
|
+
let active = 0;
|
|
792
|
+
await new Promise((resolve) => {
|
|
793
|
+
const pump = () => {
|
|
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.
|
|
802
|
+
if (entry.depth >= opts.maxDepth)
|
|
803
|
+
continue;
|
|
804
|
+
const worker = pages.pop();
|
|
805
|
+
active++;
|
|
806
|
+
const sink = [];
|
|
807
|
+
sweepState(worker, opts, entry, st, sink)
|
|
808
|
+
.then((r) => {
|
|
809
|
+
counters.tried += r.tried;
|
|
810
|
+
counters.skipped += r.skipped;
|
|
811
|
+
})
|
|
812
|
+
.catch(() => {
|
|
813
|
+
/* fail-soft: the state's surface was already captured in place */
|
|
814
|
+
})
|
|
815
|
+
.finally(() => {
|
|
816
|
+
st.queue.push(...sink);
|
|
817
|
+
pages.push(worker);
|
|
818
|
+
active--;
|
|
819
|
+
pump();
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
if (active === 0 && (st.queue.length === 0 || st.surfaces.length >= opts.maxStates || converged()))
|
|
823
|
+
resolve();
|
|
824
|
+
};
|
|
825
|
+
pump();
|
|
826
|
+
});
|
|
827
|
+
}
|
|
581
828
|
/** Depth-first discovery + in-place capture of every reachable surface. Depth-first
|
|
582
829
|
* so a surface's OWN sub-states (a modal's tab → its toggles) are mapped while the
|
|
583
830
|
* branch is fresh; with no budget, order affects time-to-depth, not coverage. */
|
|
@@ -617,7 +864,7 @@ async function discover(page, opts) {
|
|
|
617
864
|
captured: 0,
|
|
618
865
|
failed: [],
|
|
619
866
|
};
|
|
620
|
-
await record(page, opts, [], 0, fp, st, false);
|
|
867
|
+
await record(page, opts, [], 0, fp, st, st.queue, false, false);
|
|
621
868
|
// Automatic data states of the entry page — the loading skeleton and the
|
|
622
869
|
// error render exist in every data-driven app but almost never in a click
|
|
623
870
|
// path. Captured out of the box; identical-to-base renders dedup away.
|
|
@@ -625,21 +872,13 @@ async function discover(page, opts) {
|
|
|
625
872
|
await recordDataState(page, opts, 'loading', st);
|
|
626
873
|
await recordDataState(page, opts, 'error', st);
|
|
627
874
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
while (st.queue.length > 0 && st.surfaces.length < opts.maxStates) {
|
|
631
|
-
const entry = st.queue.pop(); // LIFO → depth-first
|
|
632
|
-
if (entry.depth >= opts.maxDepth)
|
|
633
|
-
continue;
|
|
634
|
-
const r = await sweepState(page, opts, entry, st);
|
|
635
|
-
actionsTried += r.tried;
|
|
636
|
-
skipped += r.skipped;
|
|
637
|
-
}
|
|
875
|
+
const counters = { tried: 0, skipped: 0 };
|
|
876
|
+
await runPool(page, opts, st, counters, defined);
|
|
638
877
|
const missing = defined.filter((c) => !st.classes.has(c)).sort();
|
|
639
878
|
return {
|
|
640
879
|
surfaces: st.surfaces,
|
|
641
|
-
actionsTried,
|
|
642
|
-
skipped,
|
|
880
|
+
actionsTried: counters.tried,
|
|
881
|
+
skipped: counters.skipped,
|
|
643
882
|
captured: st.captured,
|
|
644
883
|
failed: st.failed,
|
|
645
884
|
coverage: { defined: defined.length, rendered: defined.length - missing.length, missing },
|
|
@@ -650,19 +889,21 @@ async function discover(page, opts) {
|
|
|
650
889
|
* natural termination by default. Returns the surfaces mapped (with the click-path
|
|
651
890
|
* that reached each), how many actions were tried/skipped, and captured/failed.
|
|
652
891
|
*/
|
|
892
|
+
/** Clear storage before the app's code runs on EVERY load, so each gotoFresh is
|
|
893
|
+
* a clean slate in one navigation (no clear-then-reload round trip). */
|
|
894
|
+
async function armResetStorage(page) {
|
|
895
|
+
await page.addInitScript(() => {
|
|
896
|
+
try {
|
|
897
|
+
localStorage.clear();
|
|
898
|
+
sessionStorage.clear();
|
|
899
|
+
}
|
|
900
|
+
catch {
|
|
901
|
+
/* storage unavailable (e.g. file://) — ignore */
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
}
|
|
653
905
|
export async function crawlAndCapture(page, opts) {
|
|
654
|
-
if (opts.resetStorage)
|
|
655
|
-
|
|
656
|
-
// a clean slate in one navigation (no clear-then-reload round trip).
|
|
657
|
-
await page.addInitScript(() => {
|
|
658
|
-
try {
|
|
659
|
-
localStorage.clear();
|
|
660
|
-
sessionStorage.clear();
|
|
661
|
-
}
|
|
662
|
-
catch {
|
|
663
|
-
/* storage unavailable (e.g. file://) — ignore */
|
|
664
|
-
}
|
|
665
|
-
});
|
|
666
|
-
}
|
|
906
|
+
if (opts.resetStorage)
|
|
907
|
+
await armResetStorage(page);
|
|
667
908
|
return discover(page, opts);
|
|
668
909
|
}
|
package/dist/describe.js
CHANGED
|
@@ -18,7 +18,10 @@ const PALETTE = [
|
|
|
18
18
|
['pink', [236, 64, 122]],
|
|
19
19
|
];
|
|
20
20
|
function parseColor(v) {
|
|
21
|
-
|
|
21
|
+
// Anchored: only a value that IS a colour parses. An embedded colour inside a
|
|
22
|
+
// gradient/shadow/url must not stand in for the whole value — that once made a
|
|
23
|
+
// report show the same "representative" rgba on both sides of a real diff.
|
|
24
|
+
const m = v.match(/^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,/\s]+([\d.]+))?\s*\)$/i);
|
|
22
25
|
if (!m)
|
|
23
26
|
return null;
|
|
24
27
|
return [Number(m[1]), Number(m[2]), Number(m[3]), m[4] === undefined ? 1 : Number(m[4])];
|
package/dist/report.d.ts
CHANGED
|
@@ -71,4 +71,5 @@ export type ReportResult = {
|
|
|
71
71
|
export declare function summarizeProps(props: PropChange[]): PropChange[];
|
|
72
72
|
/** `div.who-grid`, `a.nav-cta`, `h3` — the semantic marker class, else the tag. */
|
|
73
73
|
export declare function prettyLabel(p: string, cls: string): string;
|
|
74
|
+
export declare function excerptPair(before: string, after: string): [string, string];
|
|
74
75
|
export declare function generateStyleMapReport(opts: ReportOptions): ReportResult;
|
package/dist/report.js
CHANGED
|
@@ -292,7 +292,9 @@ const orderIdx = (p) => {
|
|
|
292
292
|
return i === -1 ? PROP_ORDER.length : i;
|
|
293
293
|
};
|
|
294
294
|
function cleanVal(v) {
|
|
295
|
-
|
|
295
|
+
// Verbatim by design: no rounding. Rounding once showed alpha 0.18 as 0.2 —
|
|
296
|
+
// and could erase a real 0.18→0.2 diff entirely via the no-op filter below.
|
|
297
|
+
let s = v;
|
|
296
298
|
if (!s.includes('(')) {
|
|
297
299
|
const toks = s.split(' ');
|
|
298
300
|
if (toks.length > 1 && new Set(toks).size === 1)
|
|
@@ -512,11 +514,48 @@ function regionHeading(regionPaths, findings) {
|
|
|
512
514
|
// A "no value here" marker renders as an em dash; colours render as `#hex` so the
|
|
513
515
|
// table cell shows GitHub's live swatch.
|
|
514
516
|
const cell = (v) => (isNonValue(v) ? '—' : `\`${toHex(v)}\``);
|
|
517
|
+
// Long values (gradients, data URIs) would swamp the table, but truncating each
|
|
518
|
+
// side independently can show two IDENTICAL cells for a real diff: both
|
|
519
|
+
// sides of a gradient rendered as the same rgba while the actual change — a
|
|
520
|
+
// dropped `0px` stop — was elsewhere in the string. Instead, trim the shared
|
|
521
|
+
// prefix/suffix and show each side's differing substring with a little context.
|
|
522
|
+
const EXCERPT_AT = 64; // both sides at or under this → show whole values
|
|
523
|
+
const EXCERPT_CTX = 12; // chars of shared context kept around the diff
|
|
524
|
+
const EXCERPT_MAX = 96; // hard cap per excerpt; the diff itself may be huge
|
|
525
|
+
export function excerptPair(before, after) {
|
|
526
|
+
if (before.length <= EXCERPT_AT && after.length <= EXCERPT_AT)
|
|
527
|
+
return [before, after];
|
|
528
|
+
let p = 0;
|
|
529
|
+
while (p < before.length && p < after.length && before[p] === after[p])
|
|
530
|
+
p++;
|
|
531
|
+
let s = 0;
|
|
532
|
+
const maxS = Math.min(before.length, after.length) - p;
|
|
533
|
+
while (s < maxS && before[before.length - 1 - s] === after[after.length - 1 - s])
|
|
534
|
+
s++;
|
|
535
|
+
const cut = (v) => {
|
|
536
|
+
const start = Math.max(0, p - EXCERPT_CTX);
|
|
537
|
+
let end = Math.min(v.length, v.length - s + EXCERPT_CTX);
|
|
538
|
+
if (end - start > EXCERPT_MAX)
|
|
539
|
+
end = start + EXCERPT_MAX;
|
|
540
|
+
return (start > 0 ? '…' : '') + v.slice(start, end) + (end < v.length ? '…' : '');
|
|
541
|
+
};
|
|
542
|
+
return [cut(before), cut(after)];
|
|
543
|
+
}
|
|
544
|
+
/** Before/After cells as a pair, so long values excerpt around their actual diff. */
|
|
545
|
+
function cellPair(before, after) {
|
|
546
|
+
if (isNonValue(before) || isNonValue(after))
|
|
547
|
+
return [cell(before), cell(after)];
|
|
548
|
+
const [b, a] = excerptPair(before, after);
|
|
549
|
+
return [`\`${toHex(b)}\``, `\`${toHex(a)}\``];
|
|
550
|
+
}
|
|
515
551
|
function beforeAfterTable(rows) {
|
|
516
552
|
return [
|
|
517
553
|
'| Property | Before | After |',
|
|
518
554
|
'| --- | --- | --- |',
|
|
519
|
-
...rows.map((r) =>
|
|
555
|
+
...rows.map((r) => {
|
|
556
|
+
const [b, a] = cellPair(r.before, r.after);
|
|
557
|
+
return `| \`${r.prop}\` | ${b} | ${a} |`;
|
|
558
|
+
}),
|
|
520
559
|
];
|
|
521
560
|
}
|
|
522
561
|
// A brand-new element has no meaningful "before", so its resting style renders
|
|
@@ -546,10 +585,12 @@ function styleSection(styles, added) {
|
|
|
546
585
|
function statesSection(states, added) {
|
|
547
586
|
const rows = [];
|
|
548
587
|
for (const st of states)
|
|
549
|
-
for (const c of summarizeProps(st.props))
|
|
588
|
+
for (const c of summarizeProps(st.props)) {
|
|
589
|
+
const [b, a] = cellPair(c.before, c.after);
|
|
550
590
|
rows.push(added
|
|
551
591
|
? `| \`:${st.state}\` | \`${c.prop}\` | ${cell(c.after)} |`
|
|
552
|
-
: `| \`:${st.state}\` | \`${c.prop}\` | ${
|
|
592
|
+
: `| \`:${st.state}\` | \`${c.prop}\` | ${b} → ${a} |`);
|
|
593
|
+
}
|
|
553
594
|
if (!rows.length)
|
|
554
595
|
return [];
|
|
555
596
|
return [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "3.
|
|
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",
|