styleproof 3.4.0 → 3.5.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 +43 -0
- package/README.md +1 -1
- package/bin/styleproof-capture.mjs +5 -0
- package/dist/capture-url.d.ts +2 -0
- package/dist/capture-url.js +3 -0
- package/dist/crawl-surfaces.d.ts +10 -5
- package/dist/crawl-surfaces.js +119 -39
- 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,49 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Report tables never show an equal-looking Before/After pair for a real diff.
|
|
13
|
+
A colour embedded in a compound value (gradient, shadow) no longer stands in
|
|
14
|
+
for the whole value — `toHex` only converts values that ARE a colour — and
|
|
15
|
+
long value pairs (gradients, data URIs) are excerpted around the differing
|
|
16
|
+
substring with a little shared context on each side.
|
|
17
|
+
- Report values render verbatim: display rounding of decimals is gone (alpha
|
|
18
|
+
`0.18` was shown as `0.2`, and a real `0.18 → 0.2` change could be dropped
|
|
19
|
+
as a no-op after rounding).
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
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.
|
|
40
|
+
|
|
41
|
+
## [3.5.0] - 2026-07-02
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- Parallel crawl: `--workers <n>` (default 4) sweeps queued states concurrently,
|
|
46
|
+
each worker on its own browser context. The surface set is identical to a
|
|
47
|
+
serial crawl — dedup sets are shared, and a state's children only join the
|
|
48
|
+
queue when its sweep completes, so family retry never reads a half-built
|
|
49
|
+
changer registry. Only dup-key suffix attribution can vary with timing; pass
|
|
50
|
+
`--workers 1` for byte-stable keys. Exhaustive runs drop from hours to
|
|
51
|
+
minutes on lattice-heavy designs.
|
|
52
|
+
|
|
10
53
|
## [3.4.0] - 2026-07-02
|
|
11
54
|
|
|
12
55
|
### 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,9 @@ async function runCrawl() {
|
|
|
105
107
|
resetStorage: opts.resetStorage,
|
|
106
108
|
setup: setupSteps,
|
|
107
109
|
dataStates: opts.dataStates,
|
|
110
|
+
workers: opts.workers,
|
|
111
|
+
// each worker page in its OWN context, so storage resets can't interfere
|
|
112
|
+
newPage: async () => (await browser.newContext()).newPage(),
|
|
108
113
|
// Stream each surface as it is captured, so progress is visible live and an
|
|
109
114
|
// interrupted run still shows exactly what it mapped.
|
|
110
115
|
onSurface: (s, ok) =>
|
package/dist/capture-url.d.ts
CHANGED
|
@@ -65,6 +65,8 @@ export type CaptureUrlOptions = {
|
|
|
65
65
|
/** crawl: also capture automatic `loading`/`error` data states of the entry
|
|
66
66
|
* page (default true). */
|
|
67
67
|
dataStates: boolean;
|
|
68
|
+
/** crawl: concurrent sweep workers (default 4). 1 = byte-stable key attribution. */
|
|
69
|
+
workers: number;
|
|
68
70
|
};
|
|
69
71
|
/**
|
|
70
72
|
* Parse `styleproof-capture` argv into options. Pure and throwing so the CLI
|
package/dist/capture-url.js
CHANGED
|
@@ -32,6 +32,7 @@ const DEFAULTS = {
|
|
|
32
32
|
resetStorage: true,
|
|
33
33
|
requireFullCoverage: false,
|
|
34
34
|
dataStates: true,
|
|
35
|
+
workers: 4,
|
|
35
36
|
};
|
|
36
37
|
function positiveNumber(raw, flag) {
|
|
37
38
|
const n = Number(raw);
|
|
@@ -62,6 +63,7 @@ const VALUE_FLAGS = {
|
|
|
62
63
|
'--max-actions': (o, v) => (o.maxActionsPerState = positiveNumber(v, '--max-actions')),
|
|
63
64
|
'--max-states': (o, v) => (o.maxStates = positiveNumber(v, '--max-states')),
|
|
64
65
|
'--setup': (o, v) => (o.setupFile = v),
|
|
66
|
+
'--workers': (o, v) => (o.workers = positiveNumber(v, '--workers')),
|
|
65
67
|
};
|
|
66
68
|
const BOOL_FLAGS = {
|
|
67
69
|
'--screenshots': (o) => (o.screenshots = true),
|
|
@@ -119,6 +121,7 @@ export function parseCaptureUrlArgs(argv) {
|
|
|
119
121
|
requireFullCoverage: DEFAULTS.requireFullCoverage,
|
|
120
122
|
setupFile: undefined,
|
|
121
123
|
dataStates: DEFAULTS.dataStates,
|
|
124
|
+
workers: DEFAULTS.workers,
|
|
122
125
|
};
|
|
123
126
|
const positional = [];
|
|
124
127
|
for (let i = 0; i < argv.length; i++)
|
package/dist/crawl-surfaces.d.ts
CHANGED
|
@@ -101,6 +101,15 @@ export type SurfaceCrawlOptions = {
|
|
|
101
101
|
/** Called as each surface is recorded (captured=false when its full capture failed).
|
|
102
102
|
* Lets CLIs stream progress instead of reporting only at the end. */
|
|
103
103
|
onSurface?: (surface: CrawledSurface, captured: boolean) => void;
|
|
104
|
+
/** Concurrent sweep workers (default 4). Each worker gets its own page from
|
|
105
|
+
* `newPage`; without a factory the crawl runs single-page regardless. The
|
|
106
|
+
* surface SET is identical to a serial crawl (same dedup sets); only which
|
|
107
|
+
* path first claims a shape — and so dup-key suffixes — can vary run to run.
|
|
108
|
+
* Use workers: 1 for byte-stable key attribution. */
|
|
109
|
+
workers?: number;
|
|
110
|
+
/** Factory for worker pages — create each in its OWN browser context so
|
|
111
|
+
* storage resets cannot interfere across concurrent sweeps. */
|
|
112
|
+
newPage?: () => Promise<Page>;
|
|
104
113
|
};
|
|
105
114
|
export declare const CRAWL_DEFAULTS: {
|
|
106
115
|
height: number;
|
|
@@ -109,14 +118,10 @@ export declare const CRAWL_DEFAULTS: {
|
|
|
109
118
|
maxActionsPerState: number;
|
|
110
119
|
maxStates: number;
|
|
111
120
|
resetStorage: boolean;
|
|
121
|
+
workers: number;
|
|
112
122
|
};
|
|
113
123
|
/** Run the caller's deterministic setup steps (login, unlock, seed input). A
|
|
114
124
|
* non-optional step that fails throws loudly — a half-established gate must
|
|
115
125
|
* never silently crawl the ungated page instead. */
|
|
116
126
|
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
127
|
export declare function crawlAndCapture(page: Page, opts: SurfaceCrawlOptions): Promise<CrawlReport>;
|
package/dist/crawl-surfaces.js
CHANGED
|
@@ -11,6 +11,7 @@ export const CRAWL_DEFAULTS = {
|
|
|
11
11
|
maxActionsPerState: 100000,
|
|
12
12
|
maxStates: 100000,
|
|
13
13
|
resetStorage: true,
|
|
14
|
+
workers: 4,
|
|
14
15
|
};
|
|
15
16
|
function slug(value) {
|
|
16
17
|
return (value
|
|
@@ -77,6 +78,19 @@ function collectClickable() {
|
|
|
77
78
|
.replace(/\s+/g, ' ')
|
|
78
79
|
.trim()
|
|
79
80
|
.slice(0, 80) || el.tagName.toLowerCase();
|
|
81
|
+
const identityFor = (el) => {
|
|
82
|
+
// Tag-path only — NO classes and NO indices: classes carry state (body.alt,
|
|
83
|
+
// .on) and would re-contextualize the same control per mode; indices carry
|
|
84
|
+
// position and drift on re-render. Tag ancestry + label is what stays
|
|
85
|
+
// stable across every context the same logical control appears in.
|
|
86
|
+
const parts = [];
|
|
87
|
+
let cur = el;
|
|
88
|
+
while (cur && cur !== document.documentElement) {
|
|
89
|
+
parts.unshift(cur.tagName.toLowerCase());
|
|
90
|
+
cur = cur.parentElement;
|
|
91
|
+
}
|
|
92
|
+
return `${parts.join('>')}|${labelFor(el)}|${el.getAttribute('role') ?? ''}`;
|
|
93
|
+
};
|
|
80
94
|
// Semantic controls first (stable, meaningful), then anything styled clickable.
|
|
81
95
|
// `grab` counts too: a draggable card is routinely ALSO a click target (open on
|
|
82
96
|
// click, drag to move), and we never drag — el.click() fires no drag gesture.
|
|
@@ -117,6 +131,7 @@ function collectClickable() {
|
|
|
117
131
|
out.push({
|
|
118
132
|
action: 'fill-input',
|
|
119
133
|
selector,
|
|
134
|
+
identity: identityFor(el),
|
|
120
135
|
label: labelFor(el) === el.tagName.toLowerCase() ? (el.getAttribute('placeholder') ?? 'input') : labelFor(el),
|
|
121
136
|
reason: 'auto-fill',
|
|
122
137
|
value: AUTO_VALUE[kind] ?? 'sample text',
|
|
@@ -139,12 +154,21 @@ function collectClickable() {
|
|
|
139
154
|
if (el instanceof HTMLSelectElement) {
|
|
140
155
|
const next = [...el.options].find((o) => !o.disabled && o.value !== el.value);
|
|
141
156
|
if (next)
|
|
142
|
-
out.push({
|
|
157
|
+
out.push({
|
|
158
|
+
action: 'select-option',
|
|
159
|
+
selector,
|
|
160
|
+
identity: identityFor(el),
|
|
161
|
+
label,
|
|
162
|
+
reason: 'select-option',
|
|
163
|
+
value: next.value,
|
|
164
|
+
unsafe,
|
|
165
|
+
});
|
|
143
166
|
}
|
|
144
167
|
else {
|
|
145
168
|
out.push({
|
|
146
169
|
action: 'click',
|
|
147
170
|
selector,
|
|
171
|
+
identity: identityFor(el),
|
|
148
172
|
label,
|
|
149
173
|
reason: el.getAttribute('role') === 'tab' ? 'tab' : 'click',
|
|
150
174
|
unsafe,
|
|
@@ -412,11 +436,14 @@ function stateKey(steps) {
|
|
|
412
436
|
}
|
|
413
437
|
/** Record a newly-found surface, capture it in place (page is already there), and
|
|
414
438
|
* queue it for its own sweep. Streams progress via onSurface. */
|
|
415
|
-
async function record(page, opts, newPath, depth, fp, st, retryOnly = false) {
|
|
439
|
+
async function record(page, opts, newPath, depth, fp, st, sink, retryOnly = false, viaRetry = false) {
|
|
416
440
|
const key = deriveKey(newPath, st.used);
|
|
417
441
|
const surface = { key, depth, path: newPath, elements: fp.elements };
|
|
418
442
|
st.surfaces.push(surface);
|
|
419
|
-
|
|
443
|
+
// Children buffer in the sweep's sink and enter the shared queue only when the
|
|
444
|
+
// parent's sweep completes — family retry reads the parent's changer registry,
|
|
445
|
+
// which is only complete then. (Serial mode passes st.queue directly.)
|
|
446
|
+
sink.push({ path: newPath, depth, sig: fp.sig, retryOnly, viaRetry });
|
|
420
447
|
for (const c of fp.classes)
|
|
421
448
|
st.classes.add(c);
|
|
422
449
|
await captureAndReport(page, opts, surface, st);
|
|
@@ -451,7 +478,7 @@ async function tryInPlace(page, c) {
|
|
|
451
478
|
}
|
|
452
479
|
/** Drive one candidate from where the page stands. Returns whether the page is
|
|
453
480
|
* still in the swept state (no-op click) and whether the action was a skip. */
|
|
454
|
-
async function driveCandidate(page, opts, entry, c, st) {
|
|
481
|
+
async function driveCandidate(page, opts, entry, c, st, sink, viaRetry) {
|
|
455
482
|
// (retry-only lineage is inherited: a consumed state's descendants can also
|
|
456
483
|
// only be mode-switch views, never fresh-candidate exploration.)
|
|
457
484
|
const outcome = await tryInPlace(page, c);
|
|
@@ -468,7 +495,7 @@ async function driveCandidate(page, opts, entry, c, st) {
|
|
|
468
495
|
.catch(() => false);
|
|
469
496
|
const from = stateKey(entry.path);
|
|
470
497
|
const list = st.changersFrom.get(from) ?? [];
|
|
471
|
-
if (!list.some((x) => x.c.
|
|
498
|
+
if (!list.some((x) => x.c.identity === c.identity))
|
|
472
499
|
list.push({ c, persists });
|
|
473
500
|
st.changersFrom.set(from, list);
|
|
474
501
|
const fp = await fingerprint(page);
|
|
@@ -482,7 +509,7 @@ async function driveCandidate(page, opts, entry, c, st) {
|
|
|
482
509
|
reason: c.reason,
|
|
483
510
|
...(c.value ? { value: c.value } : {}),
|
|
484
511
|
};
|
|
485
|
-
await record(page, opts, [...entry.path, step], entry.depth + 1, fp, st, entry.retryOnly || !persists);
|
|
512
|
+
await record(page, opts, [...entry.path, step], entry.depth + 1, fp, st, sink, entry.retryOnly || !persists, viaRetry);
|
|
486
513
|
return { inState: false, skipped: false };
|
|
487
514
|
}
|
|
488
515
|
/**
|
|
@@ -494,14 +521,26 @@ async function driveCandidate(page, opts, entry, c, st) {
|
|
|
494
521
|
/** The family-retry list for a state: its parent's persistent mode-switchers that
|
|
495
522
|
* are visible right now — minus the step that created this state itself. */
|
|
496
523
|
function familyRetries(entry, all, st) {
|
|
524
|
+
// RETRIES DO NOT COMPOUND: a state reached via a family retry still explores
|
|
525
|
+
// its genuinely-new UI (fresh selectors), but never re-retries mode-switchers.
|
|
526
|
+
// Every PAIRWISE mode combination is captured; N-way products of independent
|
|
527
|
+
// toggles are not walked — they multiply states without new render vocabulary,
|
|
528
|
+
// and anything class-visible only at 3-way depth is still NAMED by the
|
|
529
|
+
// coverage verifier.
|
|
530
|
+
if (entry.viaRetry)
|
|
531
|
+
return [];
|
|
497
532
|
if (entry.path.length === 0)
|
|
498
533
|
return [];
|
|
499
534
|
const parentKey = stateKey(entry.path.slice(0, -1));
|
|
500
535
|
const ownSelector = entry.path[entry.path.length - 1].selector;
|
|
501
|
-
|
|
536
|
+
// Match registered changers to THIS state's candidates by semantic identity —
|
|
537
|
+
// the mode switch re-rendered the subtree, so positional selectors drifted;
|
|
538
|
+
// the current candidate carries the right selector for this state.
|
|
539
|
+
const byIdentity = new Map(all.map((c) => [c.identity, c]));
|
|
502
540
|
return (st.changersFrom.get(parentKey) ?? [])
|
|
503
|
-
.filter((x) => x.persists && x.c.selector !== ownSelector
|
|
504
|
-
.map((x) => x.c)
|
|
541
|
+
.filter((x) => x.persists && x.c.selector !== ownSelector)
|
|
542
|
+
.map((x) => byIdentity.get(x.c.identity))
|
|
543
|
+
.filter((c) => Boolean(c));
|
|
505
544
|
}
|
|
506
545
|
/** The work list for one state's sweep: fresh controls first (already-driven
|
|
507
546
|
* global chrome would otherwise starve a deep surface's own controls; the
|
|
@@ -509,13 +548,13 @@ function familyRetries(entry, all, st) {
|
|
|
509
548
|
* re-tried in THIS sibling mode. A state reached through a consuming action
|
|
510
549
|
* collects NO fresh candidates — see QueueEntry.retryOnly. */
|
|
511
550
|
function sweepWorkList(entry, all, opts, st) {
|
|
512
|
-
const fresh = entry.retryOnly ? [] : all.filter((c) => !st.tried.has(c.
|
|
551
|
+
const fresh = entry.retryOnly ? [] : all.filter((c) => !st.tried.has(c.identity)).slice(0, opts.maxActionsPerState);
|
|
513
552
|
return [
|
|
514
553
|
...fresh.map((c) => ({ c, retry: false })),
|
|
515
554
|
...familyRetries(entry, all, st).map((c) => ({ c, retry: true })),
|
|
516
555
|
];
|
|
517
556
|
}
|
|
518
|
-
async function sweepState(page, opts, entry, st) {
|
|
557
|
+
async function sweepState(page, opts, entry, st, sink) {
|
|
519
558
|
if (!(await resetToState(page, opts, entry.path, entry.sig)))
|
|
520
559
|
return { tried: 0, skipped: 0 };
|
|
521
560
|
const all = await page.evaluate(collectClickable).catch(() => []);
|
|
@@ -532,13 +571,13 @@ async function sweepState(page, opts, entry, st) {
|
|
|
532
571
|
inState = true;
|
|
533
572
|
}
|
|
534
573
|
if (!retry)
|
|
535
|
-
st.tried.add(c.
|
|
574
|
+
st.tried.add(c.identity);
|
|
536
575
|
if (c.unsafe) {
|
|
537
576
|
skipped++;
|
|
538
577
|
continue;
|
|
539
578
|
}
|
|
540
579
|
tried++;
|
|
541
|
-
const r = await driveCandidate(page, opts, entry, c, st);
|
|
580
|
+
const r = await driveCandidate(page, opts, entry, c, st, sink, retry);
|
|
542
581
|
inState = r.inState;
|
|
543
582
|
if (r.skipped)
|
|
544
583
|
skipped++;
|
|
@@ -578,6 +617,53 @@ async function recordDataState(page, opts, mode, st) {
|
|
|
578
617
|
await page.unroute('**/*');
|
|
579
618
|
}
|
|
580
619
|
}
|
|
620
|
+
/**
|
|
621
|
+
* Sweep the queue with N concurrent workers, each on its own page. LIFO keeps
|
|
622
|
+
* the depth-first bias; a worker's discovered children enter the shared queue
|
|
623
|
+
* only when its sweep completes (see record). The surface SET matches a serial
|
|
624
|
+
* crawl — dedup sets are shared and mutated synchronously — only dup-key
|
|
625
|
+
* suffix attribution can vary with timing.
|
|
626
|
+
*/
|
|
627
|
+
async function runPool(primary, opts, st, counters) {
|
|
628
|
+
const target = Math.max(1, opts.workers ?? 1);
|
|
629
|
+
const pages = [primary];
|
|
630
|
+
while (opts.newPage && pages.length < target) {
|
|
631
|
+
const extra = await opts.newPage();
|
|
632
|
+
if (opts.resetStorage)
|
|
633
|
+
await armResetStorage(extra);
|
|
634
|
+
pages.push(extra);
|
|
635
|
+
}
|
|
636
|
+
let active = 0;
|
|
637
|
+
await new Promise((resolve) => {
|
|
638
|
+
const pump = () => {
|
|
639
|
+
while (pages.length > 0 && st.queue.length > 0 && st.surfaces.length < opts.maxStates) {
|
|
640
|
+
const entry = st.queue.pop(); // LIFO → depth-first
|
|
641
|
+
if (entry.depth >= opts.maxDepth)
|
|
642
|
+
continue;
|
|
643
|
+
const worker = pages.pop();
|
|
644
|
+
active++;
|
|
645
|
+
const sink = [];
|
|
646
|
+
sweepState(worker, opts, entry, st, sink)
|
|
647
|
+
.then((r) => {
|
|
648
|
+
counters.tried += r.tried;
|
|
649
|
+
counters.skipped += r.skipped;
|
|
650
|
+
})
|
|
651
|
+
.catch(() => {
|
|
652
|
+
/* fail-soft: the state's surface was already captured in place */
|
|
653
|
+
})
|
|
654
|
+
.finally(() => {
|
|
655
|
+
st.queue.push(...sink);
|
|
656
|
+
pages.push(worker);
|
|
657
|
+
active--;
|
|
658
|
+
pump();
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
if (active === 0 && (st.queue.length === 0 || st.surfaces.length >= opts.maxStates))
|
|
662
|
+
resolve();
|
|
663
|
+
};
|
|
664
|
+
pump();
|
|
665
|
+
});
|
|
666
|
+
}
|
|
581
667
|
/** Depth-first discovery + in-place capture of every reachable surface. Depth-first
|
|
582
668
|
* so a surface's OWN sub-states (a modal's tab → its toggles) are mapped while the
|
|
583
669
|
* branch is fresh; with no budget, order affects time-to-depth, not coverage. */
|
|
@@ -617,7 +703,7 @@ async function discover(page, opts) {
|
|
|
617
703
|
captured: 0,
|
|
618
704
|
failed: [],
|
|
619
705
|
};
|
|
620
|
-
await record(page, opts, [], 0, fp, st, false);
|
|
706
|
+
await record(page, opts, [], 0, fp, st, st.queue, false, false);
|
|
621
707
|
// Automatic data states of the entry page — the loading skeleton and the
|
|
622
708
|
// error render exist in every data-driven app but almost never in a click
|
|
623
709
|
// path. Captured out of the box; identical-to-base renders dedup away.
|
|
@@ -625,21 +711,13 @@ async function discover(page, opts) {
|
|
|
625
711
|
await recordDataState(page, opts, 'loading', st);
|
|
626
712
|
await recordDataState(page, opts, 'error', st);
|
|
627
713
|
}
|
|
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
|
-
}
|
|
714
|
+
const counters = { tried: 0, skipped: 0 };
|
|
715
|
+
await runPool(page, opts, st, counters);
|
|
638
716
|
const missing = defined.filter((c) => !st.classes.has(c)).sort();
|
|
639
717
|
return {
|
|
640
718
|
surfaces: st.surfaces,
|
|
641
|
-
actionsTried,
|
|
642
|
-
skipped,
|
|
719
|
+
actionsTried: counters.tried,
|
|
720
|
+
skipped: counters.skipped,
|
|
643
721
|
captured: st.captured,
|
|
644
722
|
failed: st.failed,
|
|
645
723
|
coverage: { defined: defined.length, rendered: defined.length - missing.length, missing },
|
|
@@ -650,19 +728,21 @@ async function discover(page, opts) {
|
|
|
650
728
|
* natural termination by default. Returns the surfaces mapped (with the click-path
|
|
651
729
|
* that reached each), how many actions were tried/skipped, and captured/failed.
|
|
652
730
|
*/
|
|
731
|
+
/** Clear storage before the app's code runs on EVERY load, so each gotoFresh is
|
|
732
|
+
* a clean slate in one navigation (no clear-then-reload round trip). */
|
|
733
|
+
async function armResetStorage(page) {
|
|
734
|
+
await page.addInitScript(() => {
|
|
735
|
+
try {
|
|
736
|
+
localStorage.clear();
|
|
737
|
+
sessionStorage.clear();
|
|
738
|
+
}
|
|
739
|
+
catch {
|
|
740
|
+
/* storage unavailable (e.g. file://) — ignore */
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
}
|
|
653
744
|
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
|
-
}
|
|
745
|
+
if (opts.resetStorage)
|
|
746
|
+
await armResetStorage(page);
|
|
667
747
|
return discover(page, opts);
|
|
668
748
|
}
|
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.5.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",
|