styleproof 3.3.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 +91 -0
- package/README.md +42 -1
- package/bin/styleproof-capture.mjs +20 -3
- package/dist/capture-url.d.ts +19 -0
- package/dist/capture-url.js +52 -0
- package/dist/crawl-surfaces.d.ts +37 -6
- package/dist/crawl-surfaces.js +295 -47
- package/dist/describe.js +4 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- 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,97 @@ 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
|
+
|
|
53
|
+
## [3.4.0] - 2026-07-02
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- Consuming actions (controls that disappear when used — resolve/approve/dismiss
|
|
58
|
+
rows) no longer spawn a combinatorial decision lattice. Their result-states are
|
|
59
|
+
captured and swept RETRY-ONLY: the parent's persistent mode-switchers still
|
|
60
|
+
apply (a resolved list's tab view stays reachable), but no fresh candidates are
|
|
61
|
+
collected there — removed rows shift sibling nth-of-type selectors, which made
|
|
62
|
+
the same logical controls look "fresh" in every decided subset and could stall
|
|
63
|
+
an exhaustive crawl for hours adding zero coverage. Found live, on a
|
|
64
|
+
decision-heavy page whose crawl went 449 surfaces / 6 new classes before the
|
|
65
|
+
rule; with it, the same shape converges in single digits with full coverage.
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- `styleproof-capture --setup <file>`: deterministic steps (goto/fill/click/
|
|
70
|
+
waitFor) run after every fresh navigation, so input-gated states — a login,
|
|
71
|
+
an unlock code, seeded input — become crawlable. `${ENV_VAR}` in values is
|
|
72
|
+
interpolated from the environment at load time, so credentials never live in
|
|
73
|
+
the file or the maps; a failed non-optional step aborts loudly.
|
|
74
|
+
- Automatic data states: the crawl watches the entry page's data requests and
|
|
75
|
+
additionally captures `loading` (requests stalled — the skeleton) and `error`
|
|
76
|
+
(requests fulfilled with 500) out of the box. Identical-to-base renders dedup
|
|
77
|
+
away; `--no-data-states` to skip.
|
|
78
|
+
- README: "What the crawler can and cannot reach — honestly" — the crawl
|
|
79
|
+
vocabulary, what each state class is reached by, and the verifier contract
|
|
80
|
+
that anything unreached is named, never silently missed.
|
|
81
|
+
- Automatic neutral-input fill: text/search/email/tel/url/number inputs and
|
|
82
|
+
textareas are typed with a deterministic value, so input-driven states (a
|
|
83
|
+
search's results, a filter's rendering) are crawled with no config.
|
|
84
|
+
Credential-semantic fields (type=password, autocomplete username/
|
|
85
|
+
current-password/new-password/one-time-code) are never auto-filled — that is
|
|
86
|
+
`--setup` territory, the only input the tool cannot derive.
|
|
87
|
+
- Automatic scroll reveal: a bounded, deterministic scroll pass on every load
|
|
88
|
+
mounts IntersectionObserver/lazy content before discovery, so scroll-gated
|
|
89
|
+
sections are part of the mapped surface.
|
|
90
|
+
- The crawl now auto-detects the page's real `@media` breakpoints when no
|
|
91
|
+
`--widths` are given (like the one-shot path), sweeping one width per band.
|
|
92
|
+
- `--setup` steps are honoured by the one-shot (non-crawl) capture too, so a
|
|
93
|
+
gated page's single state is capturable directly.
|
|
94
|
+
|
|
95
|
+
### Fixed
|
|
96
|
+
|
|
97
|
+
- Automatic data states now intercept by resource type (fetch/XHR) instead of
|
|
98
|
+
exact URL — apps that cache-bust their requests (`?t=...`) previously
|
|
99
|
+
slipped past the stall and produced no `loading` capture.
|
|
100
|
+
|
|
10
101
|
## [3.3.0] - 2026-07-02
|
|
11
102
|
|
|
12
103
|
### Added
|
package/README.md
CHANGED
|
@@ -424,12 +424,53 @@ 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
|
|
|
431
431
|
**Destructive-looking controls (delete, deploy, pay, revoke…) are never clicked** — mapping must not mutate; states gated behind one of those need a spec. Prefer the spec-driven `defineStyleMapCapture` when you want stable, named keys and the coverage guard; reach for `--crawl` to map a design (or a third-party page) you don't have a spec for.
|
|
432
432
|
|
|
433
|
+
### Data states, out of the box
|
|
434
|
+
|
|
435
|
+
Every data-driven page has states that almost never sit on a click path: the **loading skeleton** and the **error render**. The crawl captures both automatically — it watches the entry page's data requests, then re-loads once with them **stalled** (the skeleton is the settled state, captured as `loading`) and once with them **fulfilled as 500** (captured as `error`). States that render identically to the base (e.g. server-rendered pages) dedup away silently. On by default; `--no-data-states` to skip. Deeper data states — a specific empty list, a partial payload — are fixture territory: model them as `liveStates`/`variants` in a spec.
|
|
436
|
+
|
|
437
|
+
### Input-gated states: `--setup`
|
|
438
|
+
|
|
439
|
+
A crawler clicks and selects; it does not guess your password. States behind typed input — a login, an unlock code, a seeded search — become crawlable with a deterministic setup file, run after **every** fresh navigation so each reset re-establishes the gate identically:
|
|
440
|
+
|
|
441
|
+
```json
|
|
442
|
+
[
|
|
443
|
+
{ "action": "fill", "selector": "#user", "value": "${CAPTURE_USER}" },
|
|
444
|
+
{ "action": "fill", "selector": "#pass", "value": "${CAPTURE_PASS}" },
|
|
445
|
+
{ "action": "click", "selector": "#sign-in" },
|
|
446
|
+
{ "action": "waitFor", "selector": ".dashboard" }
|
|
447
|
+
]
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
```bash
|
|
451
|
+
CAPTURE_USER=demo CAPTURE_PASS=… styleproof-capture https://example.com --crawl --setup login.json --out design
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
`${ENV_VAR}` in `value`/`url` is interpolated from the environment at load time — **credentials never live in the file, the shell history, or the captured maps.** A non-optional step that fails aborts the crawl loudly (a half-established gate must never silently crawl the ungated page); mark a step `"optional": true` when it legitimately may not apply (a cookie-session app that shows the login form only once).
|
|
455
|
+
|
|
456
|
+
### What the crawler can and cannot reach — honestly
|
|
457
|
+
|
|
458
|
+
The crawl's vocabulary is **click, select, neutral typing, scrolling, and your setup steps** — and it sweeps the page's real `@media` breakpoints automatically when you give it none. Within it, mapping is exhaustive. Outside it, states are not reached by crawling — and the coverage verifier is what keeps that honest: anything unreached is _named_, never silently missed.
|
|
459
|
+
|
|
460
|
+
| State | Reached by |
|
|
461
|
+
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
462
|
+
| Click-opened surfaces (modals, drawers, popovers, tabs, toggles) | crawl, automatically |
|
|
463
|
+
| Mode × sibling combinations (a tab's edit state, a decided list's other tab) | crawl — family retry |
|
|
464
|
+
| Loading / error data states of the entry page | crawl — automatic data states |
|
|
465
|
+
| Login / unlock / typed input | `--setup` steps |
|
|
466
|
+
| `:hover` / `:focus` / `:active` styling | the forced-state layer of every capture |
|
|
467
|
+
| Deeper data states (empty, partial, streaming) | spec `liveStates` / `variants` with fixtures |
|
|
468
|
+
| States behind destructive actions | a spec, deliberately — the crawl never clicks them |
|
|
469
|
+
| Drag-and-drop, keyboard-shortcut, scroll-triggered states | a spec driving them explicitly |
|
|
470
|
+
| Components not mounted anywhere in the UI | a component catalog page (each component per prop-state is a surface — Storybook/Ladle stories work; `discoverComponentFiles` fails CI when a component file has no captured surface) |
|
|
471
|
+
|
|
472
|
+
The rule of thumb: **a rendered state is a function of props, data, and input.** Control all three — mock the data, script the input, mount the component — and every state a component can render is a capturable surface. The verifier tells you, by name, which ones you haven't controlled yet.
|
|
473
|
+
|
|
433
474
|
## Install
|
|
434
475
|
|
|
435
476
|
```bash
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { chromium } from '@playwright/test';
|
|
20
20
|
import { isHelpArg, showHelpAndExit } from '../dist/cli-errors.js';
|
|
21
|
-
import { UsageError, parseCaptureUrlArgs, runCaptureUrl } from '../dist/capture-url.js';
|
|
21
|
+
import { UsageError, parseCaptureUrlArgs, runCaptureUrl, loadSetupSteps } from '../dist/capture-url.js';
|
|
22
22
|
import { crawlAndCapture } from '../dist/crawl-surfaces.js';
|
|
23
23
|
|
|
24
24
|
const COMMAND = 'styleproof-capture';
|
|
@@ -32,7 +32,7 @@ one state (default): capture the page as it loads
|
|
|
32
32
|
--wait <selector> wait for this selector to be visible before capturing
|
|
33
33
|
--widths <csv> viewport widths, e.g. 1440,1024,768. Omit to detect the
|
|
34
34
|
page's own @media breakpoints (fails on cross-origin CSS —
|
|
35
|
-
pass widths for those).
|
|
35
|
+
pass widths for those). The crawl auto-detects too.
|
|
36
36
|
|
|
37
37
|
whole surface: --crawl
|
|
38
38
|
--crawl EXHAUSTIVE: drive every non-destructive control, recurse into
|
|
@@ -44,6 +44,15 @@ whole surface: --crawl
|
|
|
44
44
|
exit 4 unless every class the page's stylesheets define was
|
|
45
45
|
rendered in a captured surface — the machine check that
|
|
46
46
|
NOTHING in the design was missed (coverage is always printed)
|
|
47
|
+
--setup <file> JSON steps (goto/fill/click/waitFor) run after EVERY fresh
|
|
48
|
+
navigation — how input-gated states (login, unlock) become
|
|
49
|
+
crawlable. \${ENV_VAR} in value/url is read from the
|
|
50
|
+
environment, so secrets never live in the file or the maps.
|
|
51
|
+
--no-data-states skip the automatic loading/error captures of the entry page
|
|
52
|
+
(on by default: data requests stalled → loading skeleton;
|
|
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
|
|
47
56
|
--max-depth <n> throttle recursion depth (default: unbounded)
|
|
48
57
|
--max-actions <n> throttle controls tried per state (default: unbounded)
|
|
49
58
|
--max-states <n> throttle total surfaces (default: unbounded)
|
|
@@ -67,8 +76,11 @@ const argv = process.argv.slice(2);
|
|
|
67
76
|
if (isHelpArg(argv[0])) showHelpAndExit(HELP);
|
|
68
77
|
|
|
69
78
|
let opts;
|
|
79
|
+
let setupSteps;
|
|
70
80
|
try {
|
|
71
81
|
opts = parseCaptureUrlArgs(argv);
|
|
82
|
+
setupSteps = opts.setupFile ? loadSetupSteps(opts.setupFile) : undefined;
|
|
83
|
+
opts.setup = setupSteps; // one-shot capture honours setup steps too
|
|
72
84
|
} catch (e) {
|
|
73
85
|
if (e instanceof UsageError) {
|
|
74
86
|
console.error(`${COMMAND}: ${e.message}\nNext: run ${COMMAND} --help to see supported options.`);
|
|
@@ -84,7 +96,7 @@ async function runCrawl() {
|
|
|
84
96
|
const crawlOpts = {
|
|
85
97
|
url: opts.url,
|
|
86
98
|
out: opts.out,
|
|
87
|
-
widths: opts.widths
|
|
99
|
+
widths: opts.widths, // empty = auto-detect the page's real breakpoints
|
|
88
100
|
ignore: opts.ignore,
|
|
89
101
|
height: opts.height,
|
|
90
102
|
screenshots: opts.screenshots,
|
|
@@ -93,6 +105,11 @@ async function runCrawl() {
|
|
|
93
105
|
maxActionsPerState: opts.maxActionsPerState,
|
|
94
106
|
maxStates: opts.maxStates,
|
|
95
107
|
resetStorage: opts.resetStorage,
|
|
108
|
+
setup: setupSteps,
|
|
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(),
|
|
96
113
|
// Stream each surface as it is captured, so progress is visible live and an
|
|
97
114
|
// interrupted run still shows exactly what it mapped.
|
|
98
115
|
onSurface: (s, ok) =>
|
package/dist/capture-url.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Browser, Page } from '@playwright/test';
|
|
2
|
+
import { type SetupStep } from './crawl-surfaces.js';
|
|
2
3
|
/**
|
|
3
4
|
* One-shot capture of a single URL's computed-style map — no spec, no config,
|
|
4
5
|
* no git. `defineStyleMapCapture` is the right tool when the surfaces live in
|
|
@@ -55,6 +56,17 @@ export type CaptureUrlOptions = {
|
|
|
55
56
|
/** crawl: exit non-zero unless every class the page's stylesheets define was
|
|
56
57
|
* rendered in at least one captured surface (default false — report only). */
|
|
57
58
|
requireFullCoverage: boolean;
|
|
59
|
+
/** crawl: JSON file of deterministic setup steps (login, unlock, seed input)
|
|
60
|
+
* run after every fresh navigation. See {@link loadSetupSteps}. */
|
|
61
|
+
setupFile?: string;
|
|
62
|
+
/** Loaded setup steps (set by the CLI from `setupFile`); applied after every
|
|
63
|
+
* navigation in BOTH modes, so a gated page's single state is capturable too. */
|
|
64
|
+
setup?: SetupStep[];
|
|
65
|
+
/** crawl: also capture automatic `loading`/`error` data states of the entry
|
|
66
|
+
* page (default true). */
|
|
67
|
+
dataStates: boolean;
|
|
68
|
+
/** crawl: concurrent sweep workers (default 4). 1 = byte-stable key attribution. */
|
|
69
|
+
workers: number;
|
|
58
70
|
};
|
|
59
71
|
/**
|
|
60
72
|
* Parse `styleproof-capture` argv into options. Pure and throwing so the CLI
|
|
@@ -78,3 +90,10 @@ export type CaptureUrlResult = {
|
|
|
78
90
|
export declare function captureUrlToDir(page: Page, opts: CaptureUrlOptions): Promise<CaptureUrlResult[]>;
|
|
79
91
|
/** Launch Chromium, capture the URL, and close — the whole bin body given parsed options. */
|
|
80
92
|
export declare function runCaptureUrl(opts: CaptureUrlOptions, launch: () => Promise<Browser>): Promise<CaptureUrlResult[]>;
|
|
93
|
+
/**
|
|
94
|
+
* Load and validate a `--setup` steps file, interpolating `${ENV_VAR}` in every
|
|
95
|
+
* `value` and `url` from the environment — so credentials for an input-gated
|
|
96
|
+
* page live in env vars, never in the file, the shell history, or the maps.
|
|
97
|
+
* Throws {@link UsageError} on a malformed file or a missing variable.
|
|
98
|
+
*/
|
|
99
|
+
export declare function loadSetupSteps(file: string, env?: NodeJS.ProcessEnv): SetupStep[];
|
package/dist/capture-url.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
|
|
4
4
|
import { detectViewportWidths } from './breakpoints.js';
|
|
5
|
+
import { runSetup } from './crawl-surfaces.js';
|
|
5
6
|
/**
|
|
6
7
|
* One-shot capture of a single URL's computed-style map — no spec, no config,
|
|
7
8
|
* no git. `defineStyleMapCapture` is the right tool when the surfaces live in
|
|
@@ -30,6 +31,8 @@ const DEFAULTS = {
|
|
|
30
31
|
maxStates: 100000,
|
|
31
32
|
resetStorage: true,
|
|
32
33
|
requireFullCoverage: false,
|
|
34
|
+
dataStates: true,
|
|
35
|
+
workers: 4,
|
|
33
36
|
};
|
|
34
37
|
function positiveNumber(raw, flag) {
|
|
35
38
|
const n = Number(raw);
|
|
@@ -59,6 +62,8 @@ const VALUE_FLAGS = {
|
|
|
59
62
|
'--max-depth': (o, v) => (o.maxDepth = positiveNumber(v, '--max-depth')),
|
|
60
63
|
'--max-actions': (o, v) => (o.maxActionsPerState = positiveNumber(v, '--max-actions')),
|
|
61
64
|
'--max-states': (o, v) => (o.maxStates = positiveNumber(v, '--max-states')),
|
|
65
|
+
'--setup': (o, v) => (o.setupFile = v),
|
|
66
|
+
'--workers': (o, v) => (o.workers = positiveNumber(v, '--workers')),
|
|
62
67
|
};
|
|
63
68
|
const BOOL_FLAGS = {
|
|
64
69
|
'--screenshots': (o) => (o.screenshots = true),
|
|
@@ -66,6 +71,8 @@ const BOOL_FLAGS = {
|
|
|
66
71
|
'--crawl': (o) => (o.crawl = true),
|
|
67
72
|
'--no-reset-storage': (o) => (o.resetStorage = false),
|
|
68
73
|
'--require-full-coverage': (o) => (o.requireFullCoverage = true),
|
|
74
|
+
'--data-states': (o) => (o.dataStates = true),
|
|
75
|
+
'--no-data-states': (o) => (o.dataStates = false),
|
|
69
76
|
};
|
|
70
77
|
// Apply one argv token to the accumulator; returns the index to resume from
|
|
71
78
|
// (advanced past a consumed `--flag value` pair). Flat early-returns so the
|
|
@@ -112,6 +119,9 @@ export function parseCaptureUrlArgs(argv) {
|
|
|
112
119
|
maxStates: DEFAULTS.maxStates,
|
|
113
120
|
resetStorage: DEFAULTS.resetStorage,
|
|
114
121
|
requireFullCoverage: DEFAULTS.requireFullCoverage,
|
|
122
|
+
setupFile: undefined,
|
|
123
|
+
dataStates: DEFAULTS.dataStates,
|
|
124
|
+
workers: DEFAULTS.workers,
|
|
115
125
|
};
|
|
116
126
|
const positional = [];
|
|
117
127
|
for (let i = 0; i < argv.length; i++)
|
|
@@ -139,6 +149,8 @@ export async function captureUrlToDir(page, opts) {
|
|
|
139
149
|
await page.goto(opts.url, { waitUntil: 'load' });
|
|
140
150
|
if (opts.waitSelector)
|
|
141
151
|
await page.locator(opts.waitSelector).first().waitFor({ state: 'visible' });
|
|
152
|
+
if (opts.setup?.length)
|
|
153
|
+
await runSetup(page, opts.setup);
|
|
142
154
|
widths = await detectViewportWidths(page);
|
|
143
155
|
}
|
|
144
156
|
const results = [];
|
|
@@ -149,6 +161,8 @@ export async function captureUrlToDir(page, opts) {
|
|
|
149
161
|
await page.goto(opts.url, { waitUntil: 'load' });
|
|
150
162
|
if (opts.waitSelector)
|
|
151
163
|
await page.locator(opts.waitSelector).first().waitFor({ state: 'visible' });
|
|
164
|
+
if (opts.setup?.length)
|
|
165
|
+
await runSetup(page, opts.setup);
|
|
152
166
|
const map = await captureStyleMap(page, {
|
|
153
167
|
ignore: opts.ignore,
|
|
154
168
|
pendingRequests: requests.pending,
|
|
@@ -183,3 +197,41 @@ export async function runCaptureUrl(opts, launch) {
|
|
|
183
197
|
await browser.close();
|
|
184
198
|
}
|
|
185
199
|
}
|
|
200
|
+
const SETUP_ACTIONS = new Set(['goto', 'fill', 'click', 'waitFor']);
|
|
201
|
+
/**
|
|
202
|
+
* Load and validate a `--setup` steps file, interpolating `${ENV_VAR}` in every
|
|
203
|
+
* `value` and `url` from the environment — so credentials for an input-gated
|
|
204
|
+
* page live in env vars, never in the file, the shell history, or the maps.
|
|
205
|
+
* Throws {@link UsageError} on a malformed file or a missing variable.
|
|
206
|
+
*/
|
|
207
|
+
export function loadSetupSteps(file, env = process.env) {
|
|
208
|
+
let parsed;
|
|
209
|
+
try {
|
|
210
|
+
parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
throw new UsageError(`--setup: cannot read ${file}: ${e instanceof Error ? e.message : String(e)}`);
|
|
214
|
+
}
|
|
215
|
+
if (!Array.isArray(parsed))
|
|
216
|
+
throw new UsageError('--setup: the file must be a JSON array of steps');
|
|
217
|
+
const interpolate = (raw) => raw.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => {
|
|
218
|
+
const v = env[name];
|
|
219
|
+
if (v === undefined)
|
|
220
|
+
throw new UsageError(`--setup: environment variable ${name} is not set`);
|
|
221
|
+
return v;
|
|
222
|
+
});
|
|
223
|
+
return parsed.map((raw, i) => {
|
|
224
|
+
const step = raw;
|
|
225
|
+
if (!SETUP_ACTIONS.has(step.action))
|
|
226
|
+
throw new UsageError(`--setup: step ${i} has unknown action "${String(step.action)}"`);
|
|
227
|
+
if (step.action === 'goto' && !step.url)
|
|
228
|
+
throw new UsageError(`--setup: step ${i} (goto) needs a url`);
|
|
229
|
+
if (step.action !== 'goto' && !step.selector)
|
|
230
|
+
throw new UsageError(`--setup: step ${i} (${step.action}) needs a selector`);
|
|
231
|
+
return {
|
|
232
|
+
...step,
|
|
233
|
+
...(step.url ? { url: interpolate(step.url) } : {}),
|
|
234
|
+
...(step.value ? { value: interpolate(step.value) } : {}),
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
}
|
package/dist/crawl-surfaces.d.ts
CHANGED
|
@@ -30,7 +30,7 @@ import type { Page } from '@playwright/test';
|
|
|
30
30
|
* mapping must not mutate. States gated behind such an action need a spec.
|
|
31
31
|
*/
|
|
32
32
|
export type CrawlStep = {
|
|
33
|
-
action: 'click' | 'select-option';
|
|
33
|
+
action: 'click' | 'select-option' | 'fill-input';
|
|
34
34
|
selector: string;
|
|
35
35
|
label: string;
|
|
36
36
|
reason: string;
|
|
@@ -60,6 +60,21 @@ export type CrawlReport = {
|
|
|
60
60
|
failed: string[];
|
|
61
61
|
coverage: CrawlCoverage;
|
|
62
62
|
};
|
|
63
|
+
/**
|
|
64
|
+
* One deterministic step run after every fresh navigation, BEFORE the crawl
|
|
65
|
+
* looks at the page — how input-gated states (a login form, a search box)
|
|
66
|
+
* become crawlable. Values come from the caller with `${ENV_VAR}` interpolation
|
|
67
|
+
* done at load time, so secrets live in the environment, never in files or maps.
|
|
68
|
+
* `optional: true` skips the step silently when its selector isn't present
|
|
69
|
+
* (e.g. a cookie-session app that only shows the login form once).
|
|
70
|
+
*/
|
|
71
|
+
export type SetupStep = {
|
|
72
|
+
action: 'goto' | 'fill' | 'click' | 'waitFor';
|
|
73
|
+
url?: string;
|
|
74
|
+
selector?: string;
|
|
75
|
+
value?: string;
|
|
76
|
+
optional?: boolean;
|
|
77
|
+
};
|
|
63
78
|
export type SurfaceCrawlOptions = {
|
|
64
79
|
url: string;
|
|
65
80
|
out: string;
|
|
@@ -68,6 +83,13 @@ export type SurfaceCrawlOptions = {
|
|
|
68
83
|
height: number;
|
|
69
84
|
screenshots: boolean;
|
|
70
85
|
waitSelector?: string;
|
|
86
|
+
/** Deterministic steps (login, unlock, seed input) run after EVERY fresh
|
|
87
|
+
* navigation, so each reset re-establishes the gated state identically. */
|
|
88
|
+
setup?: SetupStep[];
|
|
89
|
+
/** Also capture automatic data states of the entry page: `loading` (data
|
|
90
|
+
* requests stalled — the skeleton) and `error` (data requests fulfilled with
|
|
91
|
+
* a 500). Default true; states that render identically to base are skipped. */
|
|
92
|
+
dataStates?: boolean;
|
|
71
93
|
/** Throttle: recursion depth into opened surfaces (base = 0). Default: unbounded. */
|
|
72
94
|
maxDepth: number;
|
|
73
95
|
/** Throttle: fresh controls driven per state. Default: unbounded — try them all. */
|
|
@@ -79,6 +101,15 @@ export type SurfaceCrawlOptions = {
|
|
|
79
101
|
/** Called as each surface is recorded (captured=false when its full capture failed).
|
|
80
102
|
* Lets CLIs stream progress instead of reporting only at the end. */
|
|
81
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>;
|
|
82
113
|
};
|
|
83
114
|
export declare const CRAWL_DEFAULTS: {
|
|
84
115
|
height: number;
|
|
@@ -87,10 +118,10 @@ export declare const CRAWL_DEFAULTS: {
|
|
|
87
118
|
maxActionsPerState: number;
|
|
88
119
|
maxStates: number;
|
|
89
120
|
resetStorage: boolean;
|
|
121
|
+
workers: number;
|
|
90
122
|
};
|
|
91
|
-
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
|
|
95
|
-
*/
|
|
123
|
+
/** Run the caller's deterministic setup steps (login, unlock, seed input). A
|
|
124
|
+
* non-optional step that fails throws loudly — a half-established gate must
|
|
125
|
+
* never silently crawl the ungated page instead. */
|
|
126
|
+
export declare function runSetup(page: Page, steps: SetupStep[]): Promise<void>;
|
|
96
127
|
export declare function crawlAndCapture(page: Page, opts: SurfaceCrawlOptions): Promise<CrawlReport>;
|
package/dist/crawl-surfaces.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
|
|
5
|
+
import { detectViewportWidths } from './breakpoints.js';
|
|
5
6
|
// Exhaustive by default — these ceilings are safety backstops, not budgets.
|
|
6
7
|
export const CRAWL_DEFAULTS = {
|
|
7
8
|
height: 900,
|
|
@@ -10,6 +11,7 @@ export const CRAWL_DEFAULTS = {
|
|
|
10
11
|
maxActionsPerState: 100000,
|
|
11
12
|
maxStates: 100000,
|
|
12
13
|
resetStorage: true,
|
|
14
|
+
workers: 4,
|
|
13
15
|
};
|
|
14
16
|
function slug(value) {
|
|
15
17
|
return (value
|
|
@@ -76,6 +78,19 @@ function collectClickable() {
|
|
|
76
78
|
.replace(/\s+/g, ' ')
|
|
77
79
|
.trim()
|
|
78
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
|
+
};
|
|
79
94
|
// Semantic controls first (stable, meaningful), then anything styled clickable.
|
|
80
95
|
// `grab` counts too: a draggable card is routinely ALSO a click target (open on
|
|
81
96
|
// click, drag to move), and we never drag — el.click() fires no drag gesture.
|
|
@@ -87,8 +102,42 @@ function collectClickable() {
|
|
|
87
102
|
if (cursor === 'pointer' || cursor === 'grab')
|
|
88
103
|
pool.add(el);
|
|
89
104
|
}
|
|
105
|
+
// Neutral text inputs are typed automatically with a deterministic value —
|
|
106
|
+
// a search box or filter needs no secrets. Credential-semantic fields
|
|
107
|
+
// (type=password, autocomplete username/current-password/new-password/
|
|
108
|
+
// one-time-code) are NEVER auto-filled: those are --setup territory.
|
|
109
|
+
const FILLABLE = 'input:not([type]),input[type="text"],input[type="search"],input[type="email"],input[type="tel"],input[type="url"],input[type="number"],textarea';
|
|
110
|
+
const CRED_AUTOCOMPLETE = /username|current-password|new-password|one-time-code/i;
|
|
111
|
+
const AUTO_VALUE = {
|
|
112
|
+
email: 'sample@example.com',
|
|
113
|
+
url: 'https://example.com',
|
|
114
|
+
tel: '5550100',
|
|
115
|
+
number: '1',
|
|
116
|
+
};
|
|
90
117
|
const seen = new Set();
|
|
91
118
|
const out = [];
|
|
119
|
+
for (const el of document.querySelectorAll(FILLABLE)) {
|
|
120
|
+
if (CRED_AUTOCOMPLETE.test(el.getAttribute('autocomplete') ?? ''))
|
|
121
|
+
continue;
|
|
122
|
+
if (el.closest(':disabled,[aria-disabled="true"]') || el.readOnly)
|
|
123
|
+
continue;
|
|
124
|
+
if (!visible(el))
|
|
125
|
+
continue;
|
|
126
|
+
const selector = selectorFor(el);
|
|
127
|
+
if (seen.has(selector))
|
|
128
|
+
continue;
|
|
129
|
+
seen.add(selector);
|
|
130
|
+
const kind = el.getAttribute('type') ?? 'text';
|
|
131
|
+
out.push({
|
|
132
|
+
action: 'fill-input',
|
|
133
|
+
selector,
|
|
134
|
+
identity: identityFor(el),
|
|
135
|
+
label: labelFor(el) === el.tagName.toLowerCase() ? (el.getAttribute('placeholder') ?? 'input') : labelFor(el),
|
|
136
|
+
reason: 'auto-fill',
|
|
137
|
+
value: AUTO_VALUE[kind] ?? 'sample text',
|
|
138
|
+
unsafe: false,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
92
141
|
for (const el of pool) {
|
|
93
142
|
if (el instanceof HTMLAnchorElement && el.href)
|
|
94
143
|
continue; // links navigate — handled by link crawl, not here
|
|
@@ -105,12 +154,21 @@ function collectClickable() {
|
|
|
105
154
|
if (el instanceof HTMLSelectElement) {
|
|
106
155
|
const next = [...el.options].find((o) => !o.disabled && o.value !== el.value);
|
|
107
156
|
if (next)
|
|
108
|
-
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
|
+
});
|
|
109
166
|
}
|
|
110
167
|
else {
|
|
111
168
|
out.push({
|
|
112
169
|
action: 'click',
|
|
113
170
|
selector,
|
|
171
|
+
identity: identityFor(el),
|
|
114
172
|
label,
|
|
115
173
|
reason: el.getAttribute('role') === 'tab' ? 'tab' : 'click',
|
|
116
174
|
unsafe,
|
|
@@ -227,6 +285,59 @@ async function waitSettled(page) {
|
|
|
227
285
|
* leave the viewport wherever it finished (e.g. a mobile band where half the
|
|
228
286
|
* controls are hidden).
|
|
229
287
|
*/
|
|
288
|
+
/** One runner per setup action — a table, so adding an action is one entry. */
|
|
289
|
+
const SETUP_RUNNERS = {
|
|
290
|
+
goto: async (page, s) => {
|
|
291
|
+
await page.goto(s.url ?? '', { waitUntil: 'load' });
|
|
292
|
+
},
|
|
293
|
+
fill: async (page, s) => {
|
|
294
|
+
await page
|
|
295
|
+
.locator(s.selector ?? '')
|
|
296
|
+
.first()
|
|
297
|
+
.fill(s.value ?? '', { timeout: 10000 });
|
|
298
|
+
},
|
|
299
|
+
click: async (page, s) => {
|
|
300
|
+
await perform(page, { action: 'click', selector: s.selector ?? '' });
|
|
301
|
+
},
|
|
302
|
+
waitFor: async (page, s) => {
|
|
303
|
+
await page
|
|
304
|
+
.locator(s.selector ?? '')
|
|
305
|
+
.first()
|
|
306
|
+
.waitFor({ state: 'visible', timeout: 10000 });
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
/** Run the caller's deterministic setup steps (login, unlock, seed input). A
|
|
310
|
+
* non-optional step that fails throws loudly — a half-established gate must
|
|
311
|
+
* never silently crawl the ungated page instead. */
|
|
312
|
+
export async function runSetup(page, steps) {
|
|
313
|
+
for (const s of steps) {
|
|
314
|
+
try {
|
|
315
|
+
await SETUP_RUNNERS[s.action](page, s);
|
|
316
|
+
}
|
|
317
|
+
catch (e) {
|
|
318
|
+
if (s.optional)
|
|
319
|
+
continue;
|
|
320
|
+
throw new Error(`setup step failed (${s.action} ${s.selector ?? s.url ?? ''})`, { cause: e });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/** Reveal scroll-gated content deterministically: IntersectionObserver mounts,
|
|
325
|
+
* lazy sections. One bounded pass per load (same scroll every time, so replay
|
|
326
|
+
* and fingerprints stay stable); capped so an infinite feed can't spin it. */
|
|
327
|
+
async function scrollReveal(page) {
|
|
328
|
+
await page
|
|
329
|
+
.evaluate(async () => {
|
|
330
|
+
const step = Math.max(200, window.innerHeight);
|
|
331
|
+
let y = 0;
|
|
332
|
+
for (let i = 0; i < 20 && y <= document.body.scrollHeight; i++) {
|
|
333
|
+
window.scrollTo(0, y);
|
|
334
|
+
y += step;
|
|
335
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
336
|
+
}
|
|
337
|
+
window.scrollTo(0, 0);
|
|
338
|
+
})
|
|
339
|
+
.catch(() => { });
|
|
340
|
+
}
|
|
230
341
|
async function gotoFresh(page, opts) {
|
|
231
342
|
await page.setViewportSize({ width: opts.widths[0] ?? 1280, height: opts.height });
|
|
232
343
|
await page.goto(opts.url, { waitUntil: 'load' });
|
|
@@ -236,6 +347,12 @@ async function gotoFresh(page, opts) {
|
|
|
236
347
|
if (ready)
|
|
237
348
|
await ready.waitFor({ state: 'visible' }).catch(() => { });
|
|
238
349
|
await waitSettled(page);
|
|
350
|
+
if (opts.setup?.length) {
|
|
351
|
+
await runSetup(page, opts.setup);
|
|
352
|
+
await settleDom(page); // the steps changed page state — let it land
|
|
353
|
+
}
|
|
354
|
+
await scrollReveal(page);
|
|
355
|
+
await settleDom(page);
|
|
239
356
|
}
|
|
240
357
|
async function perform(page, s) {
|
|
241
358
|
const target = page.locator(s.selector).first();
|
|
@@ -243,6 +360,10 @@ async function perform(page, s) {
|
|
|
243
360
|
await target.selectOption(s.value ?? '');
|
|
244
361
|
return;
|
|
245
362
|
}
|
|
363
|
+
if (s.action === 'fill-input') {
|
|
364
|
+
await target.fill(s.value ?? '', { timeout: 5000 });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
246
367
|
await target.waitFor({ state: 'attached', timeout: 5000 });
|
|
247
368
|
await target.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => { });
|
|
248
369
|
// Dispatch the click IN-PAGE rather than through Playwright's actionability
|
|
@@ -315,20 +436,27 @@ function stateKey(steps) {
|
|
|
315
436
|
}
|
|
316
437
|
/** Record a newly-found surface, capture it in place (page is already there), and
|
|
317
438
|
* queue it for its own sweep. Streams progress via onSurface. */
|
|
318
|
-
async function record(page, opts, newPath, depth, fp, st) {
|
|
439
|
+
async function record(page, opts, newPath, depth, fp, st, sink, retryOnly = false, viaRetry = false) {
|
|
319
440
|
const key = deriveKey(newPath, st.used);
|
|
320
441
|
const surface = { key, depth, path: newPath, elements: fp.elements };
|
|
321
442
|
st.surfaces.push(surface);
|
|
322
|
-
|
|
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 });
|
|
323
447
|
for (const c of fp.classes)
|
|
324
448
|
st.classes.add(c);
|
|
449
|
+
await captureAndReport(page, opts, surface, st);
|
|
450
|
+
}
|
|
451
|
+
/** Capture the current page as `surface` at every width and report the outcome. */
|
|
452
|
+
async function captureAndReport(page, opts, surface, st) {
|
|
325
453
|
let ok = true;
|
|
326
454
|
try {
|
|
327
|
-
await captureInPlace(page, key, opts);
|
|
455
|
+
await captureInPlace(page, surface.key, opts);
|
|
328
456
|
st.captured++;
|
|
329
457
|
}
|
|
330
458
|
catch {
|
|
331
|
-
st.failed.push(key);
|
|
459
|
+
st.failed.push(surface.key);
|
|
332
460
|
ok = false;
|
|
333
461
|
}
|
|
334
462
|
opts.onSurface?.(surface, ok);
|
|
@@ -350,7 +478,9 @@ async function tryInPlace(page, c) {
|
|
|
350
478
|
}
|
|
351
479
|
/** Drive one candidate from where the page stands. Returns whether the page is
|
|
352
480
|
* still in the swept state (no-op click) and whether the action was a skip. */
|
|
353
|
-
async function driveCandidate(page, opts, entry, c, st) {
|
|
481
|
+
async function driveCandidate(page, opts, entry, c, st, sink, viaRetry) {
|
|
482
|
+
// (retry-only lineage is inherited: a consumed state's descendants can also
|
|
483
|
+
// only be mode-switch views, never fresh-candidate exploration.)
|
|
354
484
|
const outcome = await tryInPlace(page, c);
|
|
355
485
|
if (outcome === 'noop')
|
|
356
486
|
return { inState: true, skipped: false }; // still in the state — no reset needed
|
|
@@ -365,7 +495,7 @@ async function driveCandidate(page, opts, entry, c, st) {
|
|
|
365
495
|
.catch(() => false);
|
|
366
496
|
const from = stateKey(entry.path);
|
|
367
497
|
const list = st.changersFrom.get(from) ?? [];
|
|
368
|
-
if (!list.some((x) => x.c.
|
|
498
|
+
if (!list.some((x) => x.c.identity === c.identity))
|
|
369
499
|
list.push({ c, persists });
|
|
370
500
|
st.changersFrom.set(from, list);
|
|
371
501
|
const fp = await fingerprint(page);
|
|
@@ -379,7 +509,7 @@ async function driveCandidate(page, opts, entry, c, st) {
|
|
|
379
509
|
reason: c.reason,
|
|
380
510
|
...(c.value ? { value: c.value } : {}),
|
|
381
511
|
};
|
|
382
|
-
await record(page, opts, [...entry.path, step], entry.depth + 1, fp, st);
|
|
512
|
+
await record(page, opts, [...entry.path, step], entry.depth + 1, fp, st, sink, entry.retryOnly || !persists, viaRetry);
|
|
383
513
|
return { inState: false, skipped: false };
|
|
384
514
|
}
|
|
385
515
|
/**
|
|
@@ -391,27 +521,44 @@ async function driveCandidate(page, opts, entry, c, st) {
|
|
|
391
521
|
/** The family-retry list for a state: its parent's persistent mode-switchers that
|
|
392
522
|
* are visible right now — minus the step that created this state itself. */
|
|
393
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 [];
|
|
394
532
|
if (entry.path.length === 0)
|
|
395
533
|
return [];
|
|
396
534
|
const parentKey = stateKey(entry.path.slice(0, -1));
|
|
397
535
|
const ownSelector = entry.path[entry.path.length - 1].selector;
|
|
398
|
-
|
|
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]));
|
|
399
540
|
return (st.changersFrom.get(parentKey) ?? [])
|
|
400
|
-
.filter((x) => x.persists && x.c.selector !== ownSelector
|
|
401
|
-
.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));
|
|
402
544
|
}
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const work = [
|
|
545
|
+
/** The work list for one state's sweep: fresh controls first (already-driven
|
|
546
|
+
* global chrome would otherwise starve a deep surface's own controls; the
|
|
547
|
+
* throttle applies to fresh ones), then the parent's persistent mode-switchers
|
|
548
|
+
* 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);
|
|
552
|
+
return [
|
|
412
553
|
...fresh.map((c) => ({ c, retry: false })),
|
|
413
554
|
...familyRetries(entry, all, st).map((c) => ({ c, retry: true })),
|
|
414
555
|
];
|
|
556
|
+
}
|
|
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 };
|
|
560
|
+
const all = await page.evaluate(collectClickable).catch(() => []);
|
|
561
|
+
const work = sweepWorkList(entry, all, opts, st);
|
|
415
562
|
let tried = 0;
|
|
416
563
|
let skipped = 0;
|
|
417
564
|
let inState = true;
|
|
@@ -424,25 +571,125 @@ async function sweepState(page, opts, entry, st) {
|
|
|
424
571
|
inState = true;
|
|
425
572
|
}
|
|
426
573
|
if (!retry)
|
|
427
|
-
st.tried.add(c.
|
|
574
|
+
st.tried.add(c.identity);
|
|
428
575
|
if (c.unsafe) {
|
|
429
576
|
skipped++;
|
|
430
577
|
continue;
|
|
431
578
|
}
|
|
432
579
|
tried++;
|
|
433
|
-
const r = await driveCandidate(page, opts, entry, c, st);
|
|
580
|
+
const r = await driveCandidate(page, opts, entry, c, st, sink, retry);
|
|
434
581
|
inState = r.inState;
|
|
435
582
|
if (r.skipped)
|
|
436
583
|
skipped++;
|
|
437
584
|
}
|
|
438
585
|
return { tried, skipped };
|
|
439
586
|
}
|
|
587
|
+
/** Capture one synthetic data state of the entry page (its data requests stalled
|
|
588
|
+
* or failed) in place, deduped and coverage-counted like any surface — but never
|
|
589
|
+
* queued: a stalled app is not a state to crawl deeper from. */
|
|
590
|
+
async function recordDataState(page, opts, mode, st) {
|
|
591
|
+
// Match by resource TYPE, not by URL: real apps cache-bust (?t=...), so the
|
|
592
|
+
// re-load's data URLs never equal the observed ones. Any fetch/xhr is data.
|
|
593
|
+
await page.route('**/*', async (route) => {
|
|
594
|
+
const kind = route.request().resourceType();
|
|
595
|
+
if (kind !== 'fetch' && kind !== 'xhr')
|
|
596
|
+
return route.continue();
|
|
597
|
+
if (mode === 'error')
|
|
598
|
+
return route.fulfill({ status: 500, contentType: 'application/json', body: '{}' });
|
|
599
|
+
// loading: leave the request pending forever — the skeleton IS the state.
|
|
600
|
+
});
|
|
601
|
+
try {
|
|
602
|
+
await page.setViewportSize({ width: opts.widths[0] ?? 1280, height: opts.height });
|
|
603
|
+
await page.goto(opts.url, { waitUntil: 'load' });
|
|
604
|
+
await settleDom(page, 2500); // no networkidle wait — a stalled request never goes idle
|
|
605
|
+
const fp = await fingerprint(page);
|
|
606
|
+
if (st.seen.has(fp.sig))
|
|
607
|
+
return; // renders identically to a captured state (e.g. SSR)
|
|
608
|
+
st.seen.add(fp.sig);
|
|
609
|
+
for (const c of fp.classes)
|
|
610
|
+
st.classes.add(c);
|
|
611
|
+
const key = deriveKey([{ action: 'click', selector: `(data:${mode})`, label: mode, reason: 'data-state' }], st.used);
|
|
612
|
+
const surface = { key, depth: 0, path: [], elements: fp.elements };
|
|
613
|
+
st.surfaces.push(surface);
|
|
614
|
+
await captureAndReport(page, opts, surface, st);
|
|
615
|
+
}
|
|
616
|
+
finally {
|
|
617
|
+
await page.unroute('**/*');
|
|
618
|
+
}
|
|
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
|
+
}
|
|
440
667
|
/** Depth-first discovery + in-place capture of every reachable surface. Depth-first
|
|
441
668
|
* so a surface's OWN sub-states (a modal's tab → its toggles) are mapped while the
|
|
442
669
|
* branch is fresh; with no budget, order affects time-to-depth, not coverage. */
|
|
443
670
|
async function discover(page, opts) {
|
|
444
671
|
fs.mkdirSync(opts.out, { recursive: true });
|
|
672
|
+
// Watch the entry load's data requests so the automatic data states know what
|
|
673
|
+
// to stall/fail. Armed before navigation to see the app's own boot fetches.
|
|
674
|
+
const dataUrls = new Set();
|
|
675
|
+
const onRequest = (req) => {
|
|
676
|
+
const t = req.resourceType();
|
|
677
|
+
if (t === 'fetch' || t === 'xhr')
|
|
678
|
+
dataUrls.add(req.url());
|
|
679
|
+
};
|
|
680
|
+
page.on('request', onRequest);
|
|
445
681
|
await gotoFresh(page, opts);
|
|
682
|
+
// No widths given? Detect the page's real @media breakpoints (like the
|
|
683
|
+
// one-shot path does) and sweep one width per band — automatically. Detection
|
|
684
|
+
// reads every stylesheet; if one is cross-origin/unreadable it falls back to
|
|
685
|
+
// the single default width rather than dying.
|
|
686
|
+
if (opts.widths.length === 0) {
|
|
687
|
+
const widths = await detectViewportWidths(page).catch(() => [1280]);
|
|
688
|
+
opts = { ...opts, widths };
|
|
689
|
+
if ((widths[0] ?? 1280) !== 1280)
|
|
690
|
+
await gotoFresh(page, opts); // re-pin discovery width BEFORE the base fingerprint
|
|
691
|
+
}
|
|
692
|
+
page.off('request', onRequest);
|
|
446
693
|
const defined = await page.evaluate(collectDefinedClasses).catch(() => []);
|
|
447
694
|
const fp = await fingerprint(page);
|
|
448
695
|
const st = {
|
|
@@ -456,22 +703,21 @@ async function discover(page, opts) {
|
|
|
456
703
|
captured: 0,
|
|
457
704
|
failed: [],
|
|
458
705
|
};
|
|
459
|
-
await record(page, opts, [], 0, fp, st);
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
const r = await sweepState(page, opts, entry, st);
|
|
467
|
-
actionsTried += r.tried;
|
|
468
|
-
skipped += r.skipped;
|
|
706
|
+
await record(page, opts, [], 0, fp, st, st.queue, false, false);
|
|
707
|
+
// Automatic data states of the entry page — the loading skeleton and the
|
|
708
|
+
// error render exist in every data-driven app but almost never in a click
|
|
709
|
+
// path. Captured out of the box; identical-to-base renders dedup away.
|
|
710
|
+
if (opts.dataStates !== false && dataUrls.size > 0) {
|
|
711
|
+
await recordDataState(page, opts, 'loading', st);
|
|
712
|
+
await recordDataState(page, opts, 'error', st);
|
|
469
713
|
}
|
|
714
|
+
const counters = { tried: 0, skipped: 0 };
|
|
715
|
+
await runPool(page, opts, st, counters);
|
|
470
716
|
const missing = defined.filter((c) => !st.classes.has(c)).sort();
|
|
471
717
|
return {
|
|
472
718
|
surfaces: st.surfaces,
|
|
473
|
-
actionsTried,
|
|
474
|
-
skipped,
|
|
719
|
+
actionsTried: counters.tried,
|
|
720
|
+
skipped: counters.skipped,
|
|
475
721
|
captured: st.captured,
|
|
476
722
|
failed: st.failed,
|
|
477
723
|
coverage: { defined: defined.length, rendered: defined.length - missing.length, missing },
|
|
@@ -482,19 +728,21 @@ async function discover(page, opts) {
|
|
|
482
728
|
* natural termination by default. Returns the surfaces mapped (with the click-path
|
|
483
729
|
* that reached each), how many actions were tried/skipped, and captured/failed.
|
|
484
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
|
+
}
|
|
485
744
|
export async function crawlAndCapture(page, opts) {
|
|
486
|
-
if (opts.resetStorage)
|
|
487
|
-
|
|
488
|
-
// a clean slate in one navigation (no clear-then-reload round trip).
|
|
489
|
-
await page.addInitScript(() => {
|
|
490
|
-
try {
|
|
491
|
-
localStorage.clear();
|
|
492
|
-
sessionStorage.clear();
|
|
493
|
-
}
|
|
494
|
-
catch {
|
|
495
|
-
/* storage unavailable (e.g. file://) — ignore */
|
|
496
|
-
}
|
|
497
|
-
});
|
|
498
|
-
}
|
|
745
|
+
if (opts.resetStorage)
|
|
746
|
+
await armResetStorage(page);
|
|
499
747
|
return discover(page, opts);
|
|
500
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/index.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } fr
|
|
|
2
2
|
export { captureUrlToDir, runCaptureUrl, parseCaptureUrlArgs, UsageError } from './capture-url.js';
|
|
3
3
|
export type { CaptureUrlOptions, CaptureUrlResult } from './capture-url.js';
|
|
4
4
|
export { crawlAndCapture, CRAWL_DEFAULTS } from './crawl-surfaces.js';
|
|
5
|
-
export type { SurfaceCrawlOptions, CrawlReport, CrawlCoverage, CrawledSurface, CrawlStep } from './crawl-surfaces.js';
|
|
5
|
+
export type { SurfaceCrawlOptions, CrawlReport, CrawlCoverage, CrawledSurface, CrawlStep, SetupStep, } from './crawl-surfaces.js';
|
|
6
|
+
export { loadSetupSteps } from './capture-url.js';
|
|
6
7
|
export type { StyleMap, CaptureOptions, CaptureMetadata, ElementEntry, LiveRegionCandidate, CapturedOverlay, Rect, } from './capture.js';
|
|
7
8
|
export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
|
|
8
9
|
export type { Surface, SurfaceLiveState, SurfaceVariant, PopupCaptureOptions, DefineOptions, CrawlOptions, } from './runner.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
|
|
2
2
|
export { captureUrlToDir, runCaptureUrl, parseCaptureUrlArgs, UsageError } from './capture-url.js';
|
|
3
3
|
export { crawlAndCapture, CRAWL_DEFAULTS } from './crawl-surfaces.js';
|
|
4
|
+
export { loadSetupSteps } from './capture-url.js';
|
|
4
5
|
export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
|
|
5
6
|
export { coverageGaps } from './coverage.js';
|
|
6
7
|
export { detectViewportWidths, mediaTextWidthBoundaries, widthsFromBoundaries } from './breakpoints.js';
|
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",
|