styleproof 3.1.5 → 3.3.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.
@@ -13,6 +13,7 @@
13
13
  import fs from 'node:fs';
14
14
  import { spawnSync } from 'node:child_process';
15
15
  import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
16
17
  import {
17
18
  isHelpArg,
18
19
  missingSpecMessage,
@@ -34,6 +35,8 @@ import {
34
35
  writeMapManifest,
35
36
  } from '../dist/map-store.js';
36
37
 
38
+ const STYLEPROOF_PLAYWRIGHT_CONFIG = 'playwright.styleproof.config.ts';
39
+ const STYLEPROOF_VARIANTS_SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'styleproof-variants.mjs');
37
40
  const HELP = `styleproof-map — capture this branch's computed-style map
38
41
 
39
42
  usage: styleproof-map [options] [-- <playwright args>]
@@ -49,12 +52,28 @@ options:
49
52
  --upload require upload to the map store branch after capture
50
53
  --no-upload capture locally only (default in CI)
51
54
  --restore restore a map from the map store instead of capturing
55
+ --crawl-base-url <url>
56
+ run styleproof-variants before capture against this app URL
57
+ --crawl-route <r> route path or key=path for the pre-map variant crawl; repeatable
58
+ --crawl-out <file> variant crawl manifest (default: styleproof.variants.generated.json)
59
+ --crawl-max-actions <n>
60
+ max attempted variant actions per route (default: 40)
61
+ --crawl-width <px> pre-map crawl viewport width (default: 1280)
62
+ --crawl-height <px> pre-map crawl viewport height (default: 800)
63
+ --crawl-strict fail if live-state fixtures or skipped candidates remain
52
64
  --cache-branch <b> map store branch (default: ${DEFAULT_MAP_STORE_BRANCH})
53
65
  --remote <name> git remote for the map store (default: ${DEFAULT_REMOTE})
54
66
  -h, --help show this help
55
67
 
68
+ If playwright.styleproof.config.ts exists, styleproof-map passes it to Playwright
69
+ by default. Override with: styleproof-map -- --config playwright.config.ts
70
+
71
+ Set STYLEPROOF_CRAWL_BASE_URL and STYLEPROOF_CRAWL_ROUTES (comma-separated) to
72
+ run the same pre-map crawl from automation.
73
+
56
74
  Examples:
57
75
  styleproof-map
76
+ styleproof-map --crawl-base-url http://localhost:3000 --crawl-route / --crawl-route settings=/settings
58
77
  styleproof-map --upload
59
78
  styleproof-map --restore --sha 0123abcd --dir head --base-dir __stylemaps__
60
79
  styleproof-map --spec e2e/styleproof.spec.ts
@@ -73,6 +92,16 @@ let cacheBranch = process.env.STYLEPROOF_CACHE_BRANCH ?? DEFAULT_MAP_STORE_BRANC
73
92
  let remote = process.env.STYLEPROOF_REMOTE ?? DEFAULT_REMOTE;
74
93
  let uploadMode =
75
94
  process.env.STYLEPROOF_UPLOAD === '1' ? 'required' : process.env.STYLEPROOF_UPLOAD === '0' ? 'off' : 'auto';
95
+ let crawlBaseUrl = process.env.STYLEPROOF_CRAWL_BASE_URL ?? '';
96
+ const crawlRoutes = (process.env.STYLEPROOF_CRAWL_ROUTES ?? '')
97
+ .split(',')
98
+ .map((route) => route.trim())
99
+ .filter(Boolean);
100
+ let crawlOut = process.env.STYLEPROOF_CRAWL_OUT ?? 'styleproof.variants.generated.json';
101
+ let crawlMaxActions = process.env.STYLEPROOF_CRAWL_MAX_ACTIONS ?? '';
102
+ let crawlWidth = process.env.STYLEPROOF_CRAWL_WIDTH ?? '';
103
+ let crawlHeight = process.env.STYLEPROOF_CRAWL_HEIGHT ?? '';
104
+ let crawlStrict = process.env.STYLEPROOF_CRAWL_STRICT === '1';
76
105
  const playwrightArgs = [];
77
106
 
78
107
  for (let i = 0; i < argv.length; i++) {
@@ -95,9 +124,24 @@ for (let i = 0; i < argv.length; i++) {
95
124
  else if (a === '--upload') uploadMode = 'required';
96
125
  else if (a === '--no-upload') uploadMode = 'off';
97
126
  else if (a === '--restore') restore = true;
98
- else if (a === '--cache-branch') cacheBranch = argv[++i];
99
- else if (a.startsWith('--cache-branch=')) cacheBranch = a.slice(15);
100
- else if (a === '--remote') remote = argv[++i];
127
+ else if (a === '--crawl-base-url') crawlBaseUrl = argv[++i];
128
+ else if (a.startsWith('--crawl-base-url=')) crawlBaseUrl = a.slice(17);
129
+ else if (a === '--crawl-route') crawlRoutes.push(argv[++i]);
130
+ else if (a.startsWith('--crawl-route=')) crawlRoutes.push(a.slice(14));
131
+ else if (a === '--crawl-out') crawlOut = argv[++i];
132
+ else if (a.startsWith('--crawl-out=')) crawlOut = a.slice(12);
133
+ else if (a === '--crawl-max-actions') crawlMaxActions = argv[++i];
134
+ else if (a.startsWith('--crawl-max-actions=')) crawlMaxActions = a.slice(20);
135
+ else if (a === '--crawl-width') crawlWidth = argv[++i];
136
+ else if (a.startsWith('--crawl-width=')) crawlWidth = a.slice(14);
137
+ else if (a === '--crawl-height') crawlHeight = argv[++i];
138
+ else if (a.startsWith('--crawl-height=')) crawlHeight = a.slice(15);
139
+ else if (a === '--crawl-strict') crawlStrict = true;
140
+ else if (a === '--cache-branch' || a === '--remote') {
141
+ const value = argv[++i];
142
+ if (a === '--cache-branch') cacheBranch = value;
143
+ else remote = value;
144
+ } else if (a.startsWith('--cache-branch=')) cacheBranch = a.slice(15);
101
145
  else if (a.startsWith('--remote=')) remote = a.slice(9);
102
146
  else if (a.startsWith('--')) {
103
147
  console.error(unknownFlagMessage('styleproof-map', a));
@@ -123,6 +167,15 @@ if (!fs.existsSync(spec)) {
123
167
  console.error(missingSpecMessage(spec));
124
168
  process.exit(2);
125
169
  }
170
+ const crawlEnabled = Boolean(crawlBaseUrl || crawlRoutes.length);
171
+ if (crawlEnabled && !crawlBaseUrl) {
172
+ console.error('styleproof-map: --crawl-base-url is required when --crawl-route is set');
173
+ process.exit(2);
174
+ }
175
+ if (crawlEnabled && !crawlRoutes.length) {
176
+ console.error('styleproof-map: at least one --crawl-route is required when --crawl-base-url is set');
177
+ process.exit(2);
178
+ }
126
179
  if (restore && !sha) {
127
180
  try {
128
181
  sha = currentGitSha(process.cwd());
@@ -165,6 +218,39 @@ function upload(dirPath) {
165
218
  }
166
219
  }
167
220
 
221
+ function hasPlaywrightConfigArg(args) {
222
+ return args.some((arg) => arg === '--config' || arg === '-c' || arg.startsWith('--config='));
223
+ }
224
+
225
+ function variantCrawlArgs() {
226
+ const args = ['--base-url', crawlBaseUrl, '--out', crawlOut];
227
+ for (const route of crawlRoutes) args.push('--route', route);
228
+ if (crawlMaxActions) args.push('--max-actions', crawlMaxActions);
229
+ if (crawlWidth) args.push('--width', crawlWidth);
230
+ if (crawlHeight) args.push('--height', crawlHeight);
231
+ if (crawlStrict) args.push('--strict');
232
+ return args;
233
+ }
234
+
235
+ function runVariantCrawl(env) {
236
+ if (!crawlEnabled) return;
237
+ console.error('styleproof-map: crawling UI variants before capture');
238
+ const command = process.platform === 'win32' ? 'styleproof-variants.cmd' : 'styleproof-variants';
239
+ let result = spawnSync(command, variantCrawlArgs(), { stdio: 'inherit', env });
240
+ if (result.error?.code === 'ENOENT') {
241
+ result = spawnSync(process.execPath, [STYLEPROOF_VARIANTS_SCRIPT, ...variantCrawlArgs()], {
242
+ stdio: 'inherit',
243
+ env,
244
+ });
245
+ }
246
+ if (result.error) {
247
+ console.error(`styleproof-map: could not run styleproof-variants\n${result.error.message}`);
248
+ process.exit(2);
249
+ }
250
+ const status = result.status ?? 1;
251
+ if (status !== 0) process.exit(status);
252
+ }
253
+
168
254
  const targetDir = path.join(baseDir, dir);
169
255
  let dirtyBeforeCapture;
170
256
  try {
@@ -199,13 +285,18 @@ if (restore) {
199
285
  }
200
286
 
201
287
  const command = process.platform === 'win32' ? 'playwright.cmd' : 'playwright';
288
+ const configArgs =
289
+ fs.existsSync(STYLEPROOF_PLAYWRIGHT_CONFIG) && !hasPlaywrightConfigArg(playwrightArgs)
290
+ ? ['--config', STYLEPROOF_PLAYWRIGHT_CONFIG]
291
+ : [];
202
292
  const env = {
203
293
  ...process.env,
204
294
  STYLEMAP_DIR: dir,
205
295
  STYLEPROOF_BASEDIR: baseDir,
206
296
  STYLEPROOF_SCREENSHOTS: screenshots,
207
297
  };
208
- const result = spawnSync(command, ['test', '--grep', 'styleproof capture', ...playwrightArgs], {
298
+ runVariantCrawl(env);
299
+ const result = spawnSync(command, ['test', '--grep', 'styleproof capture', ...configArgs, ...playwrightArgs], {
209
300
  stdio: 'inherit',
210
301
  env,
211
302
  });
@@ -36,8 +36,8 @@ options:
36
36
  --remote <name> git remote for the map store (default: ${DEFAULT_REMOTE})
37
37
  --out <dir> output directory (default: styleproof-report)
38
38
  --image-base-url <url> prefix for image URLs in report.md (default: relative)
39
- --pad <px> padding around changed rects when cropping (default: 24)
40
- --max-crops <n> max crop regions per surface before collapsing (default: 6)
39
+ --pad <px> padding around changed rects when cropping (default: 12)
40
+ --max-crops <n> max crop regions per surface before collapsing (default: 8)
41
41
  --fold-details-at <n> row count at which a crop's property tables fold under a
42
42
  <details> toggle (default: 0 = always; 'Infinity' = never)
43
43
  --min-width <px> minimum crop width, for context (default: 320)
@@ -178,7 +178,7 @@ console.log(
178
178
  result.changedSurfaces === 0
179
179
  ? result.newSurfaces === 0
180
180
  ? '✓ no changes — empty report written'
181
- : `ℹ ${result.newSurfaces} new surface(s) with no baseline — report written for reference`
181
+ : `ℹ ${result.newSurfaces} new surface(s) with no baseline — report written for review`
182
182
  : `✗ ${result.changedSurfaces} changed surface(s), ${result.totalFindings} finding(s)${newNote}`,
183
183
  );
184
184
  console.log(`report: ${result.reportMdPath}`);
@@ -0,0 +1,80 @@
1
+ import type { Browser, Page } from '@playwright/test';
2
+ /**
3
+ * One-shot capture of a single URL's computed-style map — no spec, no config,
4
+ * no git. `defineStyleMapCapture` is the right tool when the surfaces live in
5
+ * your own app and you want the coverage guard, the map store, and record/replay.
6
+ * This is the tool for a page you just want to point at: a deployed URL, a
7
+ * static export, or a standalone HTML mockup. Capture it into a directory of
8
+ * `<key>@<width>.json.gz` maps (+ `.png`) that {@link diffStyleMapDirs} — i.e.
9
+ * `styleproof-diff <a> <b>` — compares like any other capture.
10
+ *
11
+ * The output is deliberately the same shape a surface capture writes, so a
12
+ * mockup's map and your app's committed map are directly diffable: capture the
13
+ * design once, then diff each build against it to measure how close the
14
+ * implementation is (a diff that shrinks toward zero as it converges).
15
+ */
16
+ /** Raised for bad CLI usage so the bin can print help and exit 2. */
17
+ export declare class UsageError extends Error {
18
+ }
19
+ export type CaptureUrlOptions = {
20
+ /** Page to capture. */
21
+ url: string;
22
+ /** Capture file name prefix (`<key>@<width>.json.gz`); default `page`. */
23
+ key: string;
24
+ /**
25
+ * Viewport widths to sweep, one per @media band. Empty = auto-detect from the
26
+ * loaded CSSOM (fails loudly on a cross-origin/unreadable stylesheet — pass
27
+ * widths explicitly for a page whose CSS can't be read, e.g. a cross-origin
28
+ * font stylesheet).
29
+ */
30
+ widths: number[];
31
+ /** Output directory for the maps (+ screenshots). */
32
+ out: string;
33
+ /** Selectors for nondeterministic regions to skip (passed through to capture). */
34
+ ignore: string[];
35
+ /** Wait for this selector to be visible before capturing (reach the intended state). */
36
+ waitSelector?: string;
37
+ /** Viewport height (default 800). */
38
+ height: number;
39
+ /** Also write a full-page `.png` per capture (default true). */
40
+ screenshots: boolean;
41
+ /**
42
+ * Crawl the URL's whole interactive surface instead of capturing one state:
43
+ * drive every non-destructive control, recurse into what opens, capture each
44
+ * discovered surface under a derived key. See {@link crawlAndCapture}.
45
+ */
46
+ crawl: boolean;
47
+ /** crawl: recursion depth into opened surfaces (default 3). */
48
+ maxDepth: number;
49
+ /** crawl: fresh controls driven per state (default: unbounded — try them all). */
50
+ maxActionsPerState: number;
51
+ /** crawl: safety backstop on total surfaces (default: unbounded — exhaustive). */
52
+ maxStates: number;
53
+ /** crawl: clear storage on each reset so replay is deterministic (default true). */
54
+ resetStorage: boolean;
55
+ /** crawl: exit non-zero unless every class the page's stylesheets define was
56
+ * rendered in at least one captured surface (default false — report only). */
57
+ requireFullCoverage: boolean;
58
+ };
59
+ /**
60
+ * Parse `styleproof-capture` argv into options. Pure and throwing so the CLI
61
+ * flow (help/exit codes) and this parse are testable without a browser.
62
+ */
63
+ export declare function parseCaptureUrlArgs(argv: string[]): CaptureUrlOptions;
64
+ /** Written artifacts for one width. */
65
+ export type CaptureUrlResult = {
66
+ width: number;
67
+ map: string;
68
+ screenshot?: string;
69
+ };
70
+ /**
71
+ * Capture `opts.url` at each width using an already-open {@link Page}, writing
72
+ * `<out>/<key>@<width>.json.gz` (+ `.png`). Re-navigates per width so
73
+ * width-dependent rendering (media queries, `matchMedia`) is captured fresh, and
74
+ * arms the in-flight request tracker before each navigation so the page's own
75
+ * load fetches count toward the network-aware settle — same contract as a
76
+ * surface capture. Returns the files written.
77
+ */
78
+ export declare function captureUrlToDir(page: Page, opts: CaptureUrlOptions): Promise<CaptureUrlResult[]>;
79
+ /** Launch Chromium, capture the URL, and close — the whole bin body given parsed options. */
80
+ export declare function runCaptureUrl(opts: CaptureUrlOptions, launch: () => Promise<Browser>): Promise<CaptureUrlResult[]>;
@@ -0,0 +1,185 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
4
+ import { detectViewportWidths } from './breakpoints.js';
5
+ /**
6
+ * One-shot capture of a single URL's computed-style map — no spec, no config,
7
+ * no git. `defineStyleMapCapture` is the right tool when the surfaces live in
8
+ * your own app and you want the coverage guard, the map store, and record/replay.
9
+ * This is the tool for a page you just want to point at: a deployed URL, a
10
+ * static export, or a standalone HTML mockup. Capture it into a directory of
11
+ * `<key>@<width>.json.gz` maps (+ `.png`) that {@link diffStyleMapDirs} — i.e.
12
+ * `styleproof-diff <a> <b>` — compares like any other capture.
13
+ *
14
+ * The output is deliberately the same shape a surface capture writes, so a
15
+ * mockup's map and your app's committed map are directly diffable: capture the
16
+ * design once, then diff each build against it to measure how close the
17
+ * implementation is (a diff that shrinks toward zero as it converges).
18
+ */
19
+ /** Raised for bad CLI usage so the bin can print help and exit 2. */
20
+ export class UsageError extends Error {
21
+ }
22
+ const DEFAULTS = {
23
+ key: 'page',
24
+ height: 800,
25
+ screenshots: true,
26
+ crawl: false,
27
+ // Exhaustive by default — these are safety backstops, not budgets.
28
+ maxDepth: 1000,
29
+ maxActionsPerState: 100000,
30
+ maxStates: 100000,
31
+ resetStorage: true,
32
+ requireFullCoverage: false,
33
+ };
34
+ function positiveNumber(raw, flag) {
35
+ const n = Number(raw);
36
+ if (!Number.isFinite(n) || n <= 0)
37
+ throw new UsageError(`${flag}: not a positive number: ${raw}`);
38
+ return n;
39
+ }
40
+ function parseWidths(raw) {
41
+ const widths = raw
42
+ .split(',')
43
+ .map((s) => s.trim())
44
+ .filter(Boolean)
45
+ .map((s) => positiveNumber(s, '--widths'));
46
+ if (widths.length === 0)
47
+ throw new UsageError('--widths: no widths given');
48
+ return widths;
49
+ }
50
+ // Table-driven so adding a flag is one entry, not another branch in the loop.
51
+ // value flags mutate the accumulator with their argument; bool flags take none.
52
+ const VALUE_FLAGS = {
53
+ '--key': (o, v) => (o.key = v),
54
+ '--widths': (o, v) => (o.widths = parseWidths(v)),
55
+ '--out': (o, v) => (o.out = v),
56
+ '--ignore': (o, v) => o.ignore.push(v),
57
+ '--wait': (o, v) => (o.waitSelector = v),
58
+ '--height': (o, v) => (o.height = positiveNumber(v, '--height')),
59
+ '--max-depth': (o, v) => (o.maxDepth = positiveNumber(v, '--max-depth')),
60
+ '--max-actions': (o, v) => (o.maxActionsPerState = positiveNumber(v, '--max-actions')),
61
+ '--max-states': (o, v) => (o.maxStates = positiveNumber(v, '--max-states')),
62
+ };
63
+ const BOOL_FLAGS = {
64
+ '--screenshots': (o) => (o.screenshots = true),
65
+ '--no-screenshots': (o) => (o.screenshots = false),
66
+ '--crawl': (o) => (o.crawl = true),
67
+ '--no-reset-storage': (o) => (o.resetStorage = false),
68
+ '--require-full-coverage': (o) => (o.requireFullCoverage = true),
69
+ };
70
+ // Apply one argv token to the accumulator; returns the index to resume from
71
+ // (advanced past a consumed `--flag value` pair). Flat early-returns so the
72
+ // parse loop stays trivial. Supports `--flag value` and `--flag=value`.
73
+ function applyArg(o, argv, i, positional) {
74
+ const a = argv[i];
75
+ const eq = a.startsWith('--') ? a.indexOf('=') : -1;
76
+ const name = eq === -1 ? a : a.slice(0, eq);
77
+ const bool = BOOL_FLAGS[name];
78
+ if (bool) {
79
+ bool(o);
80
+ return i;
81
+ }
82
+ const apply = VALUE_FLAGS[name];
83
+ if (apply) {
84
+ const v = eq === -1 ? argv[i + 1] : a.slice(eq + 1);
85
+ if (v === undefined)
86
+ throw new UsageError(`${name}: missing value`);
87
+ apply(o, v);
88
+ return eq === -1 ? i + 1 : i;
89
+ }
90
+ if (a.startsWith('--'))
91
+ throw new UsageError(`unknown flag: ${a}`);
92
+ positional.push(a);
93
+ return i;
94
+ }
95
+ /**
96
+ * Parse `styleproof-capture` argv into options. Pure and throwing so the CLI
97
+ * flow (help/exit codes) and this parse are testable without a browser.
98
+ */
99
+ export function parseCaptureUrlArgs(argv) {
100
+ const o = {
101
+ url: '',
102
+ key: DEFAULTS.key,
103
+ widths: [],
104
+ out: 'styleproof-capture',
105
+ ignore: [],
106
+ waitSelector: undefined,
107
+ height: DEFAULTS.height,
108
+ screenshots: DEFAULTS.screenshots,
109
+ crawl: DEFAULTS.crawl,
110
+ maxDepth: DEFAULTS.maxDepth,
111
+ maxActionsPerState: DEFAULTS.maxActionsPerState,
112
+ maxStates: DEFAULTS.maxStates,
113
+ resetStorage: DEFAULTS.resetStorage,
114
+ requireFullCoverage: DEFAULTS.requireFullCoverage,
115
+ };
116
+ const positional = [];
117
+ for (let i = 0; i < argv.length; i++)
118
+ i = applyArg(o, argv, i, positional);
119
+ if (positional.length === 0)
120
+ throw new UsageError('missing <url>');
121
+ if (positional.length > 1)
122
+ throw new UsageError(`expected one <url>, got ${positional.length}`);
123
+ o.url = positional[0];
124
+ return o;
125
+ }
126
+ /**
127
+ * Capture `opts.url` at each width using an already-open {@link Page}, writing
128
+ * `<out>/<key>@<width>.json.gz` (+ `.png`). Re-navigates per width so
129
+ * width-dependent rendering (media queries, `matchMedia`) is captured fresh, and
130
+ * arms the in-flight request tracker before each navigation so the page's own
131
+ * load fetches count toward the network-aware settle — same contract as a
132
+ * surface capture. Returns the files written.
133
+ */
134
+ export async function captureUrlToDir(page, opts) {
135
+ fs.mkdirSync(opts.out, { recursive: true });
136
+ let widths = opts.widths;
137
+ if (widths.length === 0) {
138
+ await page.setViewportSize({ width: 1280, height: opts.height });
139
+ await page.goto(opts.url, { waitUntil: 'load' });
140
+ if (opts.waitSelector)
141
+ await page.locator(opts.waitSelector).first().waitFor({ state: 'visible' });
142
+ widths = await detectViewportWidths(page);
143
+ }
144
+ const results = [];
145
+ for (const width of widths) {
146
+ await page.setViewportSize({ width, height: opts.height });
147
+ const requests = trackInflightRequests(page);
148
+ try {
149
+ await page.goto(opts.url, { waitUntil: 'load' });
150
+ if (opts.waitSelector)
151
+ await page.locator(opts.waitSelector).first().waitFor({ state: 'visible' });
152
+ const map = await captureStyleMap(page, {
153
+ ignore: opts.ignore,
154
+ pendingRequests: requests.pending,
155
+ metadata: { surfaceKey: opts.key },
156
+ });
157
+ const stem = path.join(opts.out, `${opts.key}@${width}`);
158
+ const mapPath = `${stem}.json.gz`;
159
+ saveStyleMap(mapPath, map);
160
+ const result = { width, map: mapPath };
161
+ if (opts.screenshots) {
162
+ const shot = `${stem}.png`;
163
+ // captureStyleMap froze animations, so the shot matches the mapped state.
164
+ await page.screenshot({ path: shot, fullPage: true, animations: 'disabled' });
165
+ result.screenshot = shot;
166
+ }
167
+ results.push(result);
168
+ }
169
+ finally {
170
+ requests.dispose();
171
+ }
172
+ }
173
+ return results;
174
+ }
175
+ /** Launch Chromium, capture the URL, and close — the whole bin body given parsed options. */
176
+ export async function runCaptureUrl(opts, launch) {
177
+ const browser = await launch();
178
+ try {
179
+ const page = await browser.newPage();
180
+ return await captureUrlToDir(page, opts);
181
+ }
182
+ finally {
183
+ await browser.close();
184
+ }
185
+ }
@@ -0,0 +1,96 @@
1
+ import type { Page } from '@playwright/test';
2
+ /**
3
+ * Surface crawler: deterministically map a single URL's WHOLE interactive
4
+ * surface — not just the state it loads in — and run to natural termination.
5
+ *
6
+ * A one-shot capture records the page as it lands, so a design that's mostly
7
+ * modals, drawers, and popovers has most of its surface behind clicks it never
8
+ * reaches. This crawls it instead: from the base state it finds every visible,
9
+ * non-destructive control (semantic controls AND anything the page styles as
10
+ * clickable — `cursor: pointer`) and drives each one, keeping whatever opens a
11
+ * structurally new surface. It then recurses depth-first into each new surface,
12
+ * so a modal's tabs, a drawer's sub-views, and a popover's panels are all mapped.
13
+ *
14
+ * EXHAUSTIVE by default: the crawl stops when the queue drains and every control
15
+ * has been driven once — not at a budget. Termination is guaranteed by dedup
16
+ * (a control is driven once by selector; a structural surface is captured once;
17
+ * both sets are finite). The `--max-*` options exist only as throttles.
18
+ *
19
+ * The sweep is IN-PLACE with lazy resets, which is what makes exhaustive
20
+ * affordable: standing in a state, each candidate is clicked where the page
21
+ * already is, and a cheap DOM fingerprint (tag+class in document order — no
22
+ * computed styles) decides what happened. A no-op click costs nothing; only a
23
+ * state-changing click pays a reset (fresh navigation + path replay), and every
24
+ * reset is VERIFIED against the state's fingerprint before the sweep continues,
25
+ * so children are never attributed to the wrong parent. New surfaces are
26
+ * captured in place the moment they're reached — a deep or animated click-path
27
+ * is never re-driven to capture, so it can't be the thing that drops a surface.
28
+ *
29
+ * Destructive-looking controls (delete, deploy, pay, revoke…) are never clicked —
30
+ * mapping must not mutate. States gated behind such an action need a spec.
31
+ */
32
+ export type CrawlStep = {
33
+ action: 'click' | 'select-option';
34
+ selector: string;
35
+ label: string;
36
+ reason: string;
37
+ value?: string;
38
+ };
39
+ export type CrawledSurface = {
40
+ key: string;
41
+ depth: number;
42
+ path: CrawlStep[];
43
+ elements: number;
44
+ };
45
+ /** Did the crawl SEE everything the design styles? `missing` lists classes the
46
+ * page's own stylesheets select on that never appeared in any captured surface —
47
+ * dead CSS, or a state the crawl could not reach. Empty missing = full coverage. */
48
+ export type CrawlCoverage = {
49
+ defined: number;
50
+ rendered: number;
51
+ missing: string[];
52
+ };
53
+ export type CrawlReport = {
54
+ surfaces: CrawledSurface[];
55
+ actionsTried: number;
56
+ skipped: number;
57
+ /** Surfaces successfully captured to disk at every width. */
58
+ captured: number;
59
+ /** Keys of surfaces discovered but whose full capture failed. */
60
+ failed: string[];
61
+ coverage: CrawlCoverage;
62
+ };
63
+ export type SurfaceCrawlOptions = {
64
+ url: string;
65
+ out: string;
66
+ widths: number[];
67
+ ignore: string[];
68
+ height: number;
69
+ screenshots: boolean;
70
+ waitSelector?: string;
71
+ /** Throttle: recursion depth into opened surfaces (base = 0). Default: unbounded. */
72
+ maxDepth: number;
73
+ /** Throttle: fresh controls driven per state. Default: unbounded — try them all. */
74
+ maxActionsPerState: number;
75
+ /** Throttle: total surfaces. Default: unbounded — run to natural termination. */
76
+ maxStates: number;
77
+ /** Clear localStorage/sessionStorage on each reset so replay is deterministic. Default true. */
78
+ resetStorage: boolean;
79
+ /** Called as each surface is recorded (captured=false when its full capture failed).
80
+ * Lets CLIs stream progress instead of reporting only at the end. */
81
+ onSurface?: (surface: CrawledSurface, captured: boolean) => void;
82
+ };
83
+ export declare const CRAWL_DEFAULTS: {
84
+ height: number;
85
+ screenshots: boolean;
86
+ maxDepth: number;
87
+ maxActionsPerState: number;
88
+ maxStates: number;
89
+ resetStorage: boolean;
90
+ };
91
+ /**
92
+ * Crawl `opts.url` and capture every reachable surface at every width — runs to
93
+ * natural termination by default. Returns the surfaces mapped (with the click-path
94
+ * that reached each), how many actions were tried/skipped, and captured/failed.
95
+ */
96
+ export declare function crawlAndCapture(page: Page, opts: SurfaceCrawlOptions): Promise<CrawlReport>;