styleproof 3.2.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.
@@ -0,0 +1,234 @@
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
+ import { runSetup } from './crawl-surfaces.js';
6
+ /**
7
+ * One-shot capture of a single URL's computed-style map — no spec, no config,
8
+ * no git. `defineStyleMapCapture` is the right tool when the surfaces live in
9
+ * your own app and you want the coverage guard, the map store, and record/replay.
10
+ * This is the tool for a page you just want to point at: a deployed URL, a
11
+ * static export, or a standalone HTML mockup. Capture it into a directory of
12
+ * `<key>@<width>.json.gz` maps (+ `.png`) that {@link diffStyleMapDirs} — i.e.
13
+ * `styleproof-diff <a> <b>` — compares like any other capture.
14
+ *
15
+ * The output is deliberately the same shape a surface capture writes, so a
16
+ * mockup's map and your app's committed map are directly diffable: capture the
17
+ * design once, then diff each build against it to measure how close the
18
+ * implementation is (a diff that shrinks toward zero as it converges).
19
+ */
20
+ /** Raised for bad CLI usage so the bin can print help and exit 2. */
21
+ export class UsageError extends Error {
22
+ }
23
+ const DEFAULTS = {
24
+ key: 'page',
25
+ height: 800,
26
+ screenshots: true,
27
+ crawl: false,
28
+ // Exhaustive by default — these are safety backstops, not budgets.
29
+ maxDepth: 1000,
30
+ maxActionsPerState: 100000,
31
+ maxStates: 100000,
32
+ resetStorage: true,
33
+ requireFullCoverage: false,
34
+ dataStates: true,
35
+ };
36
+ function positiveNumber(raw, flag) {
37
+ const n = Number(raw);
38
+ if (!Number.isFinite(n) || n <= 0)
39
+ throw new UsageError(`${flag}: not a positive number: ${raw}`);
40
+ return n;
41
+ }
42
+ function parseWidths(raw) {
43
+ const widths = raw
44
+ .split(',')
45
+ .map((s) => s.trim())
46
+ .filter(Boolean)
47
+ .map((s) => positiveNumber(s, '--widths'));
48
+ if (widths.length === 0)
49
+ throw new UsageError('--widths: no widths given');
50
+ return widths;
51
+ }
52
+ // Table-driven so adding a flag is one entry, not another branch in the loop.
53
+ // value flags mutate the accumulator with their argument; bool flags take none.
54
+ const VALUE_FLAGS = {
55
+ '--key': (o, v) => (o.key = v),
56
+ '--widths': (o, v) => (o.widths = parseWidths(v)),
57
+ '--out': (o, v) => (o.out = v),
58
+ '--ignore': (o, v) => o.ignore.push(v),
59
+ '--wait': (o, v) => (o.waitSelector = v),
60
+ '--height': (o, v) => (o.height = positiveNumber(v, '--height')),
61
+ '--max-depth': (o, v) => (o.maxDepth = positiveNumber(v, '--max-depth')),
62
+ '--max-actions': (o, v) => (o.maxActionsPerState = positiveNumber(v, '--max-actions')),
63
+ '--max-states': (o, v) => (o.maxStates = positiveNumber(v, '--max-states')),
64
+ '--setup': (o, v) => (o.setupFile = v),
65
+ };
66
+ const BOOL_FLAGS = {
67
+ '--screenshots': (o) => (o.screenshots = true),
68
+ '--no-screenshots': (o) => (o.screenshots = false),
69
+ '--crawl': (o) => (o.crawl = true),
70
+ '--no-reset-storage': (o) => (o.resetStorage = false),
71
+ '--require-full-coverage': (o) => (o.requireFullCoverage = true),
72
+ '--data-states': (o) => (o.dataStates = true),
73
+ '--no-data-states': (o) => (o.dataStates = false),
74
+ };
75
+ // Apply one argv token to the accumulator; returns the index to resume from
76
+ // (advanced past a consumed `--flag value` pair). Flat early-returns so the
77
+ // parse loop stays trivial. Supports `--flag value` and `--flag=value`.
78
+ function applyArg(o, argv, i, positional) {
79
+ const a = argv[i];
80
+ const eq = a.startsWith('--') ? a.indexOf('=') : -1;
81
+ const name = eq === -1 ? a : a.slice(0, eq);
82
+ const bool = BOOL_FLAGS[name];
83
+ if (bool) {
84
+ bool(o);
85
+ return i;
86
+ }
87
+ const apply = VALUE_FLAGS[name];
88
+ if (apply) {
89
+ const v = eq === -1 ? argv[i + 1] : a.slice(eq + 1);
90
+ if (v === undefined)
91
+ throw new UsageError(`${name}: missing value`);
92
+ apply(o, v);
93
+ return eq === -1 ? i + 1 : i;
94
+ }
95
+ if (a.startsWith('--'))
96
+ throw new UsageError(`unknown flag: ${a}`);
97
+ positional.push(a);
98
+ return i;
99
+ }
100
+ /**
101
+ * Parse `styleproof-capture` argv into options. Pure and throwing so the CLI
102
+ * flow (help/exit codes) and this parse are testable without a browser.
103
+ */
104
+ export function parseCaptureUrlArgs(argv) {
105
+ const o = {
106
+ url: '',
107
+ key: DEFAULTS.key,
108
+ widths: [],
109
+ out: 'styleproof-capture',
110
+ ignore: [],
111
+ waitSelector: undefined,
112
+ height: DEFAULTS.height,
113
+ screenshots: DEFAULTS.screenshots,
114
+ crawl: DEFAULTS.crawl,
115
+ maxDepth: DEFAULTS.maxDepth,
116
+ maxActionsPerState: DEFAULTS.maxActionsPerState,
117
+ maxStates: DEFAULTS.maxStates,
118
+ resetStorage: DEFAULTS.resetStorage,
119
+ requireFullCoverage: DEFAULTS.requireFullCoverage,
120
+ setupFile: undefined,
121
+ dataStates: DEFAULTS.dataStates,
122
+ };
123
+ const positional = [];
124
+ for (let i = 0; i < argv.length; i++)
125
+ i = applyArg(o, argv, i, positional);
126
+ if (positional.length === 0)
127
+ throw new UsageError('missing <url>');
128
+ if (positional.length > 1)
129
+ throw new UsageError(`expected one <url>, got ${positional.length}`);
130
+ o.url = positional[0];
131
+ return o;
132
+ }
133
+ /**
134
+ * Capture `opts.url` at each width using an already-open {@link Page}, writing
135
+ * `<out>/<key>@<width>.json.gz` (+ `.png`). Re-navigates per width so
136
+ * width-dependent rendering (media queries, `matchMedia`) is captured fresh, and
137
+ * arms the in-flight request tracker before each navigation so the page's own
138
+ * load fetches count toward the network-aware settle — same contract as a
139
+ * surface capture. Returns the files written.
140
+ */
141
+ export async function captureUrlToDir(page, opts) {
142
+ fs.mkdirSync(opts.out, { recursive: true });
143
+ let widths = opts.widths;
144
+ if (widths.length === 0) {
145
+ await page.setViewportSize({ width: 1280, height: opts.height });
146
+ await page.goto(opts.url, { waitUntil: 'load' });
147
+ if (opts.waitSelector)
148
+ await page.locator(opts.waitSelector).first().waitFor({ state: 'visible' });
149
+ if (opts.setup?.length)
150
+ await runSetup(page, opts.setup);
151
+ widths = await detectViewportWidths(page);
152
+ }
153
+ const results = [];
154
+ for (const width of widths) {
155
+ await page.setViewportSize({ width, height: opts.height });
156
+ const requests = trackInflightRequests(page);
157
+ try {
158
+ await page.goto(opts.url, { waitUntil: 'load' });
159
+ if (opts.waitSelector)
160
+ await page.locator(opts.waitSelector).first().waitFor({ state: 'visible' });
161
+ if (opts.setup?.length)
162
+ await runSetup(page, opts.setup);
163
+ const map = await captureStyleMap(page, {
164
+ ignore: opts.ignore,
165
+ pendingRequests: requests.pending,
166
+ metadata: { surfaceKey: opts.key },
167
+ });
168
+ const stem = path.join(opts.out, `${opts.key}@${width}`);
169
+ const mapPath = `${stem}.json.gz`;
170
+ saveStyleMap(mapPath, map);
171
+ const result = { width, map: mapPath };
172
+ if (opts.screenshots) {
173
+ const shot = `${stem}.png`;
174
+ // captureStyleMap froze animations, so the shot matches the mapped state.
175
+ await page.screenshot({ path: shot, fullPage: true, animations: 'disabled' });
176
+ result.screenshot = shot;
177
+ }
178
+ results.push(result);
179
+ }
180
+ finally {
181
+ requests.dispose();
182
+ }
183
+ }
184
+ return results;
185
+ }
186
+ /** Launch Chromium, capture the URL, and close — the whole bin body given parsed options. */
187
+ export async function runCaptureUrl(opts, launch) {
188
+ const browser = await launch();
189
+ try {
190
+ const page = await browser.newPage();
191
+ return await captureUrlToDir(page, opts);
192
+ }
193
+ finally {
194
+ await browser.close();
195
+ }
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
+ }
@@ -0,0 +1,122 @@
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' | 'fill-input';
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
+ /**
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
+ };
78
+ export type SurfaceCrawlOptions = {
79
+ url: string;
80
+ out: string;
81
+ widths: number[];
82
+ ignore: string[];
83
+ height: number;
84
+ screenshots: boolean;
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;
93
+ /** Throttle: recursion depth into opened surfaces (base = 0). Default: unbounded. */
94
+ maxDepth: number;
95
+ /** Throttle: fresh controls driven per state. Default: unbounded — try them all. */
96
+ maxActionsPerState: number;
97
+ /** Throttle: total surfaces. Default: unbounded — run to natural termination. */
98
+ maxStates: number;
99
+ /** Clear localStorage/sessionStorage on each reset so replay is deterministic. Default true. */
100
+ resetStorage: boolean;
101
+ /** Called as each surface is recorded (captured=false when its full capture failed).
102
+ * Lets CLIs stream progress instead of reporting only at the end. */
103
+ onSurface?: (surface: CrawledSurface, captured: boolean) => void;
104
+ };
105
+ export declare const CRAWL_DEFAULTS: {
106
+ height: number;
107
+ screenshots: boolean;
108
+ maxDepth: number;
109
+ maxActionsPerState: number;
110
+ maxStates: number;
111
+ resetStorage: boolean;
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>;
117
+ /**
118
+ * Crawl `opts.url` and capture every reachable surface at every width — runs to
119
+ * natural termination by default. Returns the surfaces mapped (with the click-path
120
+ * that reached each), how many actions were tried/skipped, and captured/failed.
121
+ */
122
+ export declare function crawlAndCapture(page: Page, opts: SurfaceCrawlOptions): Promise<CrawlReport>;