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.
- package/CHANGELOG.md +91 -0
- package/README.md +79 -5
- package/bin/styleproof-capture.mjs +148 -0
- package/bin/styleproof-diff.mjs +1 -1
- package/bin/styleproof-report.mjs +3 -3
- package/dist/capture-url.d.ts +97 -0
- package/dist/capture-url.js +234 -0
- package/dist/crawl-surfaces.d.ts +122 -0
- package/dist/crawl-surfaces.js +668 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/report.js +5 -7
- package/package.json +2 -1
|
@@ -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>;
|