styleproof 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,54 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.4.0] - 2026-07-02
11
+
12
+ ### Fixed
13
+
14
+ - Consuming actions (controls that disappear when used — resolve/approve/dismiss
15
+ rows) no longer spawn a combinatorial decision lattice. Their result-states are
16
+ captured and swept RETRY-ONLY: the parent's persistent mode-switchers still
17
+ apply (a resolved list's tab view stays reachable), but no fresh candidates are
18
+ collected there — removed rows shift sibling nth-of-type selectors, which made
19
+ the same logical controls look "fresh" in every decided subset and could stall
20
+ an exhaustive crawl for hours adding zero coverage. Found live, on a
21
+ decision-heavy page whose crawl went 449 surfaces / 6 new classes before the
22
+ rule; with it, the same shape converges in single digits with full coverage.
23
+
24
+ ### Added
25
+
26
+ - `styleproof-capture --setup <file>`: deterministic steps (goto/fill/click/
27
+ waitFor) run after every fresh navigation, so input-gated states — a login,
28
+ an unlock code, seeded input — become crawlable. `${ENV_VAR}` in values is
29
+ interpolated from the environment at load time, so credentials never live in
30
+ the file or the maps; a failed non-optional step aborts loudly.
31
+ - Automatic data states: the crawl watches the entry page's data requests and
32
+ additionally captures `loading` (requests stalled — the skeleton) and `error`
33
+ (requests fulfilled with 500) out of the box. Identical-to-base renders dedup
34
+ away; `--no-data-states` to skip.
35
+ - README: "What the crawler can and cannot reach — honestly" — the crawl
36
+ vocabulary, what each state class is reached by, and the verifier contract
37
+ that anything unreached is named, never silently missed.
38
+ - Automatic neutral-input fill: text/search/email/tel/url/number inputs and
39
+ textareas are typed with a deterministic value, so input-driven states (a
40
+ search's results, a filter's rendering) are crawled with no config.
41
+ Credential-semantic fields (type=password, autocomplete username/
42
+ current-password/new-password/one-time-code) are never auto-filled — that is
43
+ `--setup` territory, the only input the tool cannot derive.
44
+ - Automatic scroll reveal: a bounded, deterministic scroll pass on every load
45
+ mounts IntersectionObserver/lazy content before discovery, so scroll-gated
46
+ sections are part of the mapped surface.
47
+ - The crawl now auto-detects the page's real `@media` breakpoints when no
48
+ `--widths` are given (like the one-shot path), sweeping one width per band.
49
+ - `--setup` steps are honoured by the one-shot (non-crawl) capture too, so a
50
+ gated page's single state is capturable directly.
51
+
52
+ ### Fixed
53
+
54
+ - Automatic data states now intercept by resource type (fetch/XHR) instead of
55
+ exact URL — apps that cache-bust their requests (`?t=...`) previously
56
+ slipped past the stall and produced no `loading` capture.
57
+
10
58
  ## [3.3.0] - 2026-07-02
11
59
 
12
60
  ### Added
package/README.md CHANGED
@@ -430,6 +430,47 @@ What makes exhaustive affordable is that the sweep works **in place**: standing
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). Crawl defaults to 1280 when omitted.
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,13 @@ 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)
47
54
  --max-depth <n> throttle recursion depth (default: unbounded)
48
55
  --max-actions <n> throttle controls tried per state (default: unbounded)
49
56
  --max-states <n> throttle total surfaces (default: unbounded)
@@ -67,8 +74,11 @@ const argv = process.argv.slice(2);
67
74
  if (isHelpArg(argv[0])) showHelpAndExit(HELP);
68
75
 
69
76
  let opts;
77
+ let setupSteps;
70
78
  try {
71
79
  opts = parseCaptureUrlArgs(argv);
80
+ setupSteps = opts.setupFile ? loadSetupSteps(opts.setupFile) : undefined;
81
+ opts.setup = setupSteps; // one-shot capture honours setup steps too
72
82
  } catch (e) {
73
83
  if (e instanceof UsageError) {
74
84
  console.error(`${COMMAND}: ${e.message}\nNext: run ${COMMAND} --help to see supported options.`);
@@ -84,7 +94,7 @@ async function runCrawl() {
84
94
  const crawlOpts = {
85
95
  url: opts.url,
86
96
  out: opts.out,
87
- widths: opts.widths.length ? opts.widths : [1280],
97
+ widths: opts.widths, // empty = auto-detect the page's real breakpoints
88
98
  ignore: opts.ignore,
89
99
  height: opts.height,
90
100
  screenshots: opts.screenshots,
@@ -93,6 +103,8 @@ async function runCrawl() {
93
103
  maxActionsPerState: opts.maxActionsPerState,
94
104
  maxStates: opts.maxStates,
95
105
  resetStorage: opts.resetStorage,
106
+ setup: setupSteps,
107
+ dataStates: opts.dataStates,
96
108
  // Stream each surface as it is captured, so progress is visible live and an
97
109
  // interrupted run still shows exactly what it mapped.
98
110
  onSurface: (s, ok) =>
@@ -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,15 @@ 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;
58
68
  };
59
69
  /**
60
70
  * Parse `styleproof-capture` argv into options. Pure and throwing so the CLI
@@ -78,3 +88,10 @@ export type CaptureUrlResult = {
78
88
  export declare function captureUrlToDir(page: Page, opts: CaptureUrlOptions): Promise<CaptureUrlResult[]>;
79
89
  /** Launch Chromium, capture the URL, and close — the whole bin body given parsed options. */
80
90
  export declare function runCaptureUrl(opts: CaptureUrlOptions, launch: () => Promise<Browser>): Promise<CaptureUrlResult[]>;
91
+ /**
92
+ * Load and validate a `--setup` steps file, interpolating `${ENV_VAR}` in every
93
+ * `value` and `url` from the environment — so credentials for an input-gated
94
+ * page live in env vars, never in the file, the shell history, or the maps.
95
+ * Throws {@link UsageError} on a malformed file or a missing variable.
96
+ */
97
+ export declare function loadSetupSteps(file: string, env?: NodeJS.ProcessEnv): SetupStep[];
@@ -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,7 @@ const DEFAULTS = {
30
31
  maxStates: 100000,
31
32
  resetStorage: true,
32
33
  requireFullCoverage: false,
34
+ dataStates: true,
33
35
  };
34
36
  function positiveNumber(raw, flag) {
35
37
  const n = Number(raw);
@@ -59,6 +61,7 @@ const VALUE_FLAGS = {
59
61
  '--max-depth': (o, v) => (o.maxDepth = positiveNumber(v, '--max-depth')),
60
62
  '--max-actions': (o, v) => (o.maxActionsPerState = positiveNumber(v, '--max-actions')),
61
63
  '--max-states': (o, v) => (o.maxStates = positiveNumber(v, '--max-states')),
64
+ '--setup': (o, v) => (o.setupFile = v),
62
65
  };
63
66
  const BOOL_FLAGS = {
64
67
  '--screenshots': (o) => (o.screenshots = true),
@@ -66,6 +69,8 @@ const BOOL_FLAGS = {
66
69
  '--crawl': (o) => (o.crawl = true),
67
70
  '--no-reset-storage': (o) => (o.resetStorage = false),
68
71
  '--require-full-coverage': (o) => (o.requireFullCoverage = true),
72
+ '--data-states': (o) => (o.dataStates = true),
73
+ '--no-data-states': (o) => (o.dataStates = false),
69
74
  };
70
75
  // Apply one argv token to the accumulator; returns the index to resume from
71
76
  // (advanced past a consumed `--flag value` pair). Flat early-returns so the
@@ -112,6 +117,8 @@ export function parseCaptureUrlArgs(argv) {
112
117
  maxStates: DEFAULTS.maxStates,
113
118
  resetStorage: DEFAULTS.resetStorage,
114
119
  requireFullCoverage: DEFAULTS.requireFullCoverage,
120
+ setupFile: undefined,
121
+ dataStates: DEFAULTS.dataStates,
115
122
  };
116
123
  const positional = [];
117
124
  for (let i = 0; i < argv.length; i++)
@@ -139,6 +146,8 @@ export async function captureUrlToDir(page, opts) {
139
146
  await page.goto(opts.url, { waitUntil: 'load' });
140
147
  if (opts.waitSelector)
141
148
  await page.locator(opts.waitSelector).first().waitFor({ state: 'visible' });
149
+ if (opts.setup?.length)
150
+ await runSetup(page, opts.setup);
142
151
  widths = await detectViewportWidths(page);
143
152
  }
144
153
  const results = [];
@@ -149,6 +158,8 @@ export async function captureUrlToDir(page, opts) {
149
158
  await page.goto(opts.url, { waitUntil: 'load' });
150
159
  if (opts.waitSelector)
151
160
  await page.locator(opts.waitSelector).first().waitFor({ state: 'visible' });
161
+ if (opts.setup?.length)
162
+ await runSetup(page, opts.setup);
152
163
  const map = await captureStyleMap(page, {
153
164
  ignore: opts.ignore,
154
165
  pendingRequests: requests.pending,
@@ -183,3 +194,41 @@ export async function runCaptureUrl(opts, launch) {
183
194
  await browser.close();
184
195
  }
185
196
  }
197
+ const SETUP_ACTIONS = new Set(['goto', 'fill', 'click', 'waitFor']);
198
+ /**
199
+ * Load and validate a `--setup` steps file, interpolating `${ENV_VAR}` in every
200
+ * `value` and `url` from the environment — so credentials for an input-gated
201
+ * page live in env vars, never in the file, the shell history, or the maps.
202
+ * Throws {@link UsageError} on a malformed file or a missing variable.
203
+ */
204
+ export function loadSetupSteps(file, env = process.env) {
205
+ let parsed;
206
+ try {
207
+ parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
208
+ }
209
+ catch (e) {
210
+ throw new UsageError(`--setup: cannot read ${file}: ${e instanceof Error ? e.message : String(e)}`);
211
+ }
212
+ if (!Array.isArray(parsed))
213
+ throw new UsageError('--setup: the file must be a JSON array of steps');
214
+ const interpolate = (raw) => raw.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => {
215
+ const v = env[name];
216
+ if (v === undefined)
217
+ throw new UsageError(`--setup: environment variable ${name} is not set`);
218
+ return v;
219
+ });
220
+ return parsed.map((raw, i) => {
221
+ const step = raw;
222
+ if (!SETUP_ACTIONS.has(step.action))
223
+ throw new UsageError(`--setup: step ${i} has unknown action "${String(step.action)}"`);
224
+ if (step.action === 'goto' && !step.url)
225
+ throw new UsageError(`--setup: step ${i} (goto) needs a url`);
226
+ if (step.action !== 'goto' && !step.selector)
227
+ throw new UsageError(`--setup: step ${i} (${step.action}) needs a selector`);
228
+ return {
229
+ ...step,
230
+ ...(step.url ? { url: interpolate(step.url) } : {}),
231
+ ...(step.value ? { value: interpolate(step.value) } : {}),
232
+ };
233
+ });
234
+ }
@@ -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. */
@@ -88,6 +110,10 @@ export declare const CRAWL_DEFAULTS: {
88
110
  maxStates: number;
89
111
  resetStorage: boolean;
90
112
  };
113
+ /** Run the caller's deterministic setup steps (login, unlock, seed input). A
114
+ * non-optional step that fails throws loudly — a half-established gate must
115
+ * never silently crawl the ungated page instead. */
116
+ export declare function runSetup(page: Page, steps: SetupStep[]): Promise<void>;
91
117
  /**
92
118
  * Crawl `opts.url` and capture every reachable surface at every width — runs to
93
119
  * natural termination by default. Returns the surfaces mapped (with the click-path
@@ -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,
@@ -87,8 +88,41 @@ function collectClickable() {
87
88
  if (cursor === 'pointer' || cursor === 'grab')
88
89
  pool.add(el);
89
90
  }
91
+ // Neutral text inputs are typed automatically with a deterministic value —
92
+ // a search box or filter needs no secrets. Credential-semantic fields
93
+ // (type=password, autocomplete username/current-password/new-password/
94
+ // one-time-code) are NEVER auto-filled: those are --setup territory.
95
+ const FILLABLE = 'input:not([type]),input[type="text"],input[type="search"],input[type="email"],input[type="tel"],input[type="url"],input[type="number"],textarea';
96
+ const CRED_AUTOCOMPLETE = /username|current-password|new-password|one-time-code/i;
97
+ const AUTO_VALUE = {
98
+ email: 'sample@example.com',
99
+ url: 'https://example.com',
100
+ tel: '5550100',
101
+ number: '1',
102
+ };
90
103
  const seen = new Set();
91
104
  const out = [];
105
+ for (const el of document.querySelectorAll(FILLABLE)) {
106
+ if (CRED_AUTOCOMPLETE.test(el.getAttribute('autocomplete') ?? ''))
107
+ continue;
108
+ if (el.closest(':disabled,[aria-disabled="true"]') || el.readOnly)
109
+ continue;
110
+ if (!visible(el))
111
+ continue;
112
+ const selector = selectorFor(el);
113
+ if (seen.has(selector))
114
+ continue;
115
+ seen.add(selector);
116
+ const kind = el.getAttribute('type') ?? 'text';
117
+ out.push({
118
+ action: 'fill-input',
119
+ selector,
120
+ label: labelFor(el) === el.tagName.toLowerCase() ? (el.getAttribute('placeholder') ?? 'input') : labelFor(el),
121
+ reason: 'auto-fill',
122
+ value: AUTO_VALUE[kind] ?? 'sample text',
123
+ unsafe: false,
124
+ });
125
+ }
92
126
  for (const el of pool) {
93
127
  if (el instanceof HTMLAnchorElement && el.href)
94
128
  continue; // links navigate — handled by link crawl, not here
@@ -227,6 +261,59 @@ async function waitSettled(page) {
227
261
  * leave the viewport wherever it finished (e.g. a mobile band where half the
228
262
  * controls are hidden).
229
263
  */
264
+ /** One runner per setup action — a table, so adding an action is one entry. */
265
+ const SETUP_RUNNERS = {
266
+ goto: async (page, s) => {
267
+ await page.goto(s.url ?? '', { waitUntil: 'load' });
268
+ },
269
+ fill: async (page, s) => {
270
+ await page
271
+ .locator(s.selector ?? '')
272
+ .first()
273
+ .fill(s.value ?? '', { timeout: 10000 });
274
+ },
275
+ click: async (page, s) => {
276
+ await perform(page, { action: 'click', selector: s.selector ?? '' });
277
+ },
278
+ waitFor: async (page, s) => {
279
+ await page
280
+ .locator(s.selector ?? '')
281
+ .first()
282
+ .waitFor({ state: 'visible', timeout: 10000 });
283
+ },
284
+ };
285
+ /** Run the caller's deterministic setup steps (login, unlock, seed input). A
286
+ * non-optional step that fails throws loudly — a half-established gate must
287
+ * never silently crawl the ungated page instead. */
288
+ export async function runSetup(page, steps) {
289
+ for (const s of steps) {
290
+ try {
291
+ await SETUP_RUNNERS[s.action](page, s);
292
+ }
293
+ catch (e) {
294
+ if (s.optional)
295
+ continue;
296
+ throw new Error(`setup step failed (${s.action} ${s.selector ?? s.url ?? ''})`, { cause: e });
297
+ }
298
+ }
299
+ }
300
+ /** Reveal scroll-gated content deterministically: IntersectionObserver mounts,
301
+ * lazy sections. One bounded pass per load (same scroll every time, so replay
302
+ * and fingerprints stay stable); capped so an infinite feed can't spin it. */
303
+ async function scrollReveal(page) {
304
+ await page
305
+ .evaluate(async () => {
306
+ const step = Math.max(200, window.innerHeight);
307
+ let y = 0;
308
+ for (let i = 0; i < 20 && y <= document.body.scrollHeight; i++) {
309
+ window.scrollTo(0, y);
310
+ y += step;
311
+ await new Promise((r) => setTimeout(r, 60));
312
+ }
313
+ window.scrollTo(0, 0);
314
+ })
315
+ .catch(() => { });
316
+ }
230
317
  async function gotoFresh(page, opts) {
231
318
  await page.setViewportSize({ width: opts.widths[0] ?? 1280, height: opts.height });
232
319
  await page.goto(opts.url, { waitUntil: 'load' });
@@ -236,6 +323,12 @@ async function gotoFresh(page, opts) {
236
323
  if (ready)
237
324
  await ready.waitFor({ state: 'visible' }).catch(() => { });
238
325
  await waitSettled(page);
326
+ if (opts.setup?.length) {
327
+ await runSetup(page, opts.setup);
328
+ await settleDom(page); // the steps changed page state — let it land
329
+ }
330
+ await scrollReveal(page);
331
+ await settleDom(page);
239
332
  }
240
333
  async function perform(page, s) {
241
334
  const target = page.locator(s.selector).first();
@@ -243,6 +336,10 @@ async function perform(page, s) {
243
336
  await target.selectOption(s.value ?? '');
244
337
  return;
245
338
  }
339
+ if (s.action === 'fill-input') {
340
+ await target.fill(s.value ?? '', { timeout: 5000 });
341
+ return;
342
+ }
246
343
  await target.waitFor({ state: 'attached', timeout: 5000 });
247
344
  await target.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => { });
248
345
  // Dispatch the click IN-PAGE rather than through Playwright's actionability
@@ -315,20 +412,24 @@ function stateKey(steps) {
315
412
  }
316
413
  /** Record a newly-found surface, capture it in place (page is already there), and
317
414
  * queue it for its own sweep. Streams progress via onSurface. */
318
- async function record(page, opts, newPath, depth, fp, st) {
415
+ async function record(page, opts, newPath, depth, fp, st, retryOnly = false) {
319
416
  const key = deriveKey(newPath, st.used);
320
417
  const surface = { key, depth, path: newPath, elements: fp.elements };
321
418
  st.surfaces.push(surface);
322
- st.queue.push({ path: newPath, depth, sig: fp.sig });
419
+ st.queue.push({ path: newPath, depth, sig: fp.sig, retryOnly });
323
420
  for (const c of fp.classes)
324
421
  st.classes.add(c);
422
+ await captureAndReport(page, opts, surface, st);
423
+ }
424
+ /** Capture the current page as `surface` at every width and report the outcome. */
425
+ async function captureAndReport(page, opts, surface, st) {
325
426
  let ok = true;
326
427
  try {
327
- await captureInPlace(page, key, opts);
428
+ await captureInPlace(page, surface.key, opts);
328
429
  st.captured++;
329
430
  }
330
431
  catch {
331
- st.failed.push(key);
432
+ st.failed.push(surface.key);
332
433
  ok = false;
333
434
  }
334
435
  opts.onSurface?.(surface, ok);
@@ -351,6 +452,8 @@ async function tryInPlace(page, c) {
351
452
  /** Drive one candidate from where the page stands. Returns whether the page is
352
453
  * still in the swept state (no-op click) and whether the action was a skip. */
353
454
  async function driveCandidate(page, opts, entry, c, st) {
455
+ // (retry-only lineage is inherited: a consumed state's descendants can also
456
+ // only be mode-switch views, never fresh-candidate exploration.)
354
457
  const outcome = await tryInPlace(page, c);
355
458
  if (outcome === 'noop')
356
459
  return { inState: true, skipped: false }; // still in the state — no reset needed
@@ -379,7 +482,7 @@ async function driveCandidate(page, opts, entry, c, st) {
379
482
  reason: c.reason,
380
483
  ...(c.value ? { value: c.value } : {}),
381
484
  };
382
- await record(page, opts, [...entry.path, step], entry.depth + 1, fp, st);
485
+ await record(page, opts, [...entry.path, step], entry.depth + 1, fp, st, entry.retryOnly || !persists);
383
486
  return { inState: false, skipped: false };
384
487
  }
385
488
  /**
@@ -400,18 +503,23 @@ function familyRetries(entry, all, st) {
400
503
  .filter((x) => x.persists && x.c.selector !== ownSelector && visibleNow.has(x.c.selector))
401
504
  .map((x) => x.c);
402
505
  }
506
+ /** The work list for one state's sweep: fresh controls first (already-driven
507
+ * global chrome would otherwise starve a deep surface's own controls; the
508
+ * throttle applies to fresh ones), then the parent's persistent mode-switchers
509
+ * re-tried in THIS sibling mode. A state reached through a consuming action
510
+ * collects NO fresh candidates — see QueueEntry.retryOnly. */
511
+ function sweepWorkList(entry, all, opts, st) {
512
+ const fresh = entry.retryOnly ? [] : all.filter((c) => !st.tried.has(c.selector)).slice(0, opts.maxActionsPerState);
513
+ return [
514
+ ...fresh.map((c) => ({ c, retry: false })),
515
+ ...familyRetries(entry, all, st).map((c) => ({ c, retry: true })),
516
+ ];
517
+ }
403
518
  async function sweepState(page, opts, entry, st) {
404
519
  if (!(await resetToState(page, opts, entry.path, entry.sig)))
405
520
  return { tried: 0, skipped: 0 };
406
521
  const all = await page.evaluate(collectClickable).catch(() => []);
407
- // Fresh controls first (already-driven global chrome would otherwise starve a
408
- // deep surface's own controls; the throttle applies to fresh ones), then the
409
- // parent's persistent mode-switchers re-tried in THIS sibling mode.
410
- const fresh = all.filter((c) => !st.tried.has(c.selector)).slice(0, opts.maxActionsPerState);
411
- const work = [
412
- ...fresh.map((c) => ({ c, retry: false })),
413
- ...familyRetries(entry, all, st).map((c) => ({ c, retry: true })),
414
- ];
522
+ const work = sweepWorkList(entry, all, opts, st);
415
523
  let tried = 0;
416
524
  let skipped = 0;
417
525
  let inState = true;
@@ -437,12 +545,65 @@ async function sweepState(page, opts, entry, st) {
437
545
  }
438
546
  return { tried, skipped };
439
547
  }
548
+ /** Capture one synthetic data state of the entry page (its data requests stalled
549
+ * or failed) in place, deduped and coverage-counted like any surface — but never
550
+ * queued: a stalled app is not a state to crawl deeper from. */
551
+ async function recordDataState(page, opts, mode, st) {
552
+ // Match by resource TYPE, not by URL: real apps cache-bust (?t=...), so the
553
+ // re-load's data URLs never equal the observed ones. Any fetch/xhr is data.
554
+ await page.route('**/*', async (route) => {
555
+ const kind = route.request().resourceType();
556
+ if (kind !== 'fetch' && kind !== 'xhr')
557
+ return route.continue();
558
+ if (mode === 'error')
559
+ return route.fulfill({ status: 500, contentType: 'application/json', body: '{}' });
560
+ // loading: leave the request pending forever — the skeleton IS the state.
561
+ });
562
+ try {
563
+ await page.setViewportSize({ width: opts.widths[0] ?? 1280, height: opts.height });
564
+ await page.goto(opts.url, { waitUntil: 'load' });
565
+ await settleDom(page, 2500); // no networkidle wait — a stalled request never goes idle
566
+ const fp = await fingerprint(page);
567
+ if (st.seen.has(fp.sig))
568
+ return; // renders identically to a captured state (e.g. SSR)
569
+ st.seen.add(fp.sig);
570
+ for (const c of fp.classes)
571
+ st.classes.add(c);
572
+ const key = deriveKey([{ action: 'click', selector: `(data:${mode})`, label: mode, reason: 'data-state' }], st.used);
573
+ const surface = { key, depth: 0, path: [], elements: fp.elements };
574
+ st.surfaces.push(surface);
575
+ await captureAndReport(page, opts, surface, st);
576
+ }
577
+ finally {
578
+ await page.unroute('**/*');
579
+ }
580
+ }
440
581
  /** Depth-first discovery + in-place capture of every reachable surface. Depth-first
441
582
  * so a surface's OWN sub-states (a modal's tab → its toggles) are mapped while the
442
583
  * branch is fresh; with no budget, order affects time-to-depth, not coverage. */
443
584
  async function discover(page, opts) {
444
585
  fs.mkdirSync(opts.out, { recursive: true });
586
+ // Watch the entry load's data requests so the automatic data states know what
587
+ // to stall/fail. Armed before navigation to see the app's own boot fetches.
588
+ const dataUrls = new Set();
589
+ const onRequest = (req) => {
590
+ const t = req.resourceType();
591
+ if (t === 'fetch' || t === 'xhr')
592
+ dataUrls.add(req.url());
593
+ };
594
+ page.on('request', onRequest);
445
595
  await gotoFresh(page, opts);
596
+ // No widths given? Detect the page's real @media breakpoints (like the
597
+ // one-shot path does) and sweep one width per band — automatically. Detection
598
+ // reads every stylesheet; if one is cross-origin/unreadable it falls back to
599
+ // the single default width rather than dying.
600
+ if (opts.widths.length === 0) {
601
+ const widths = await detectViewportWidths(page).catch(() => [1280]);
602
+ opts = { ...opts, widths };
603
+ if ((widths[0] ?? 1280) !== 1280)
604
+ await gotoFresh(page, opts); // re-pin discovery width BEFORE the base fingerprint
605
+ }
606
+ page.off('request', onRequest);
446
607
  const defined = await page.evaluate(collectDefinedClasses).catch(() => []);
447
608
  const fp = await fingerprint(page);
448
609
  const st = {
@@ -456,7 +617,14 @@ async function discover(page, opts) {
456
617
  captured: 0,
457
618
  failed: [],
458
619
  };
459
- await record(page, opts, [], 0, fp, st);
620
+ await record(page, opts, [], 0, fp, st, false);
621
+ // Automatic data states of the entry page — the loading skeleton and the
622
+ // error render exist in every data-driven app but almost never in a click
623
+ // path. Captured out of the box; identical-to-base renders dedup away.
624
+ if (opts.dataStates !== false && dataUrls.size > 0) {
625
+ await recordDataState(page, opts, 'loading', st);
626
+ await recordDataState(page, opts, 'error', st);
627
+ }
460
628
  let actionsTried = 0;
461
629
  let skipped = 0;
462
630
  while (st.queue.length > 0 && st.surfaces.length < opts.maxStates) {
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.3.0",
3
+ "version": "3.4.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",