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 +48 -0
- package/README.md +41 -0
- package/bin/styleproof-capture.mjs +15 -3
- package/dist/capture-url.d.ts +17 -0
- package/dist/capture-url.js +49 -0
- package/dist/crawl-surfaces.d.ts +27 -1
- package/dist/crawl-surfaces.js +182 -14
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/package.json +1 -1
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).
|
|
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
|
|
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) =>
|
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,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[];
|
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,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
|
+
}
|
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. */
|
|
@@ -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
|
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,
|
|
@@ -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
|
-
|
|
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
|
+
"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",
|