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.
- package/CHANGELOG.md +61 -1
- package/README.md +85 -25
- package/bin/styleproof-capture.mjs +136 -0
- package/bin/styleproof-diff.mjs +1 -1
- package/bin/styleproof-init.mjs +81 -19
- package/bin/styleproof-map.mjs +95 -4
- package/bin/styleproof-report.mjs +3 -3
- package/dist/capture-url.d.ts +80 -0
- package/dist/capture-url.js +185 -0
- package/dist/crawl-surfaces.d.ts +96 -0
- package/dist/crawl-surfaces.js +500 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/map-store.js +9 -1
- package/dist/report.js +5 -7
- package/package.json +2 -1
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
|
|
5
|
+
// Exhaustive by default — these ceilings are safety backstops, not budgets.
|
|
6
|
+
export const CRAWL_DEFAULTS = {
|
|
7
|
+
height: 900,
|
|
8
|
+
screenshots: true,
|
|
9
|
+
maxDepth: 1000,
|
|
10
|
+
maxActionsPerState: 100000,
|
|
11
|
+
maxStates: 100000,
|
|
12
|
+
resetStorage: true,
|
|
13
|
+
};
|
|
14
|
+
function slug(value) {
|
|
15
|
+
return (value
|
|
16
|
+
.toLowerCase()
|
|
17
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
18
|
+
.replace(/^-+|-+$/g, '')
|
|
19
|
+
.slice(0, 24) || 'state');
|
|
20
|
+
}
|
|
21
|
+
function pathAndSearch(url) {
|
|
22
|
+
try {
|
|
23
|
+
const u = new URL(url);
|
|
24
|
+
return u.pathname + u.search;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return url;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function deriveKey(steps, used) {
|
|
31
|
+
const base = steps.length === 0 ? 'base' : slug(steps[steps.length - 1].label);
|
|
32
|
+
let key = base;
|
|
33
|
+
for (let i = 2; used.has(key); i++)
|
|
34
|
+
key = `${base}-${i}`;
|
|
35
|
+
used.add(key);
|
|
36
|
+
return key;
|
|
37
|
+
}
|
|
38
|
+
/** Runs in the browser: every visible, enabled, non-navigating control worth trying. */
|
|
39
|
+
/* c8 ignore start */ // fallow-ignore-next-line complexity
|
|
40
|
+
function collectClickable() {
|
|
41
|
+
const SEMANTIC = 'button,summary,[role="button"],[role="tab"],[role="menuitem"],[role="combobox"],select,form';
|
|
42
|
+
const DANGER = /\b(delete|remove|destroy|logout|log ?out|sign ?out|publish|deploy|pay|purchase|buy|checkout|archive|disconnect|revoke|reset|wipe|drop)\b/i;
|
|
43
|
+
const esc = (v) => CSS.escape(v);
|
|
44
|
+
const quote = (v) => JSON.stringify(v);
|
|
45
|
+
const visible = (el) => {
|
|
46
|
+
const b = el.getBoundingClientRect();
|
|
47
|
+
const cs = getComputedStyle(el);
|
|
48
|
+
return (b.width > 0 && b.height > 0 && cs.display !== 'none' && cs.visibility !== 'hidden' && cs.pointerEvents !== 'none');
|
|
49
|
+
};
|
|
50
|
+
const unique = (s) => document.querySelectorAll(s).length === 1;
|
|
51
|
+
const pathSelector = (el) => {
|
|
52
|
+
const parts = [];
|
|
53
|
+
let cur = el;
|
|
54
|
+
while (cur && cur !== document.documentElement) {
|
|
55
|
+
let i = 1;
|
|
56
|
+
for (let sib = cur.previousElementSibling; sib; sib = sib.previousElementSibling)
|
|
57
|
+
if (sib.tagName === cur.tagName)
|
|
58
|
+
i++;
|
|
59
|
+
parts.unshift(`${cur.tagName.toLowerCase()}:nth-of-type(${i})`);
|
|
60
|
+
cur = cur.parentElement;
|
|
61
|
+
}
|
|
62
|
+
return parts.join(' > ');
|
|
63
|
+
};
|
|
64
|
+
const selectorFor = (el) => {
|
|
65
|
+
const id = el.getAttribute('id');
|
|
66
|
+
if (id && unique(`#${esc(id)}`))
|
|
67
|
+
return `#${esc(id)}`;
|
|
68
|
+
for (const attr of ['data-testid', 'data-test', 'aria-label', 'name']) {
|
|
69
|
+
const v = el.getAttribute(attr);
|
|
70
|
+
if (v && unique(`${el.tagName.toLowerCase()}[${attr}=${quote(v)}]`))
|
|
71
|
+
return `${el.tagName.toLowerCase()}[${attr}=${quote(v)}]`;
|
|
72
|
+
}
|
|
73
|
+
return pathSelector(el);
|
|
74
|
+
};
|
|
75
|
+
const labelFor = (el) => (el.getAttribute('aria-label') || el.getAttribute('name') || el.textContent || '')
|
|
76
|
+
.replace(/\s+/g, ' ')
|
|
77
|
+
.trim()
|
|
78
|
+
.slice(0, 80) || el.tagName.toLowerCase();
|
|
79
|
+
// Semantic controls first (stable, meaningful), then anything styled clickable.
|
|
80
|
+
// `grab` counts too: a draggable card is routinely ALSO a click target (open on
|
|
81
|
+
// click, drag to move), and we never drag — el.click() fires no drag gesture.
|
|
82
|
+
const pool = new Set([...document.querySelectorAll(SEMANTIC)]);
|
|
83
|
+
for (const el of document.querySelectorAll('body *')) {
|
|
84
|
+
if (pool.has(el))
|
|
85
|
+
continue;
|
|
86
|
+
const cursor = getComputedStyle(el).cursor;
|
|
87
|
+
if (cursor === 'pointer' || cursor === 'grab')
|
|
88
|
+
pool.add(el);
|
|
89
|
+
}
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const el of pool) {
|
|
93
|
+
if (el instanceof HTMLAnchorElement && el.href)
|
|
94
|
+
continue; // links navigate — handled by link crawl, not here
|
|
95
|
+
if (el.closest(':disabled,[aria-disabled="true"]'))
|
|
96
|
+
continue;
|
|
97
|
+
if (!visible(el))
|
|
98
|
+
continue;
|
|
99
|
+
const selector = selectorFor(el);
|
|
100
|
+
if (seen.has(selector))
|
|
101
|
+
continue;
|
|
102
|
+
seen.add(selector);
|
|
103
|
+
const label = labelFor(el);
|
|
104
|
+
const unsafe = DANGER.test(label);
|
|
105
|
+
if (el instanceof HTMLSelectElement) {
|
|
106
|
+
const next = [...el.options].find((o) => !o.disabled && o.value !== el.value);
|
|
107
|
+
if (next)
|
|
108
|
+
out.push({ action: 'select-option', selector, label, reason: 'select-option', value: next.value, unsafe });
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
out.push({
|
|
112
|
+
action: 'click',
|
|
113
|
+
selector,
|
|
114
|
+
label,
|
|
115
|
+
reason: el.getAttribute('role') === 'tab' ? 'tab' : 'click',
|
|
116
|
+
unsafe,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
/* c8 ignore stop */
|
|
123
|
+
/** Runs in the browser: the DOM's structural shape — every element's tag + class
|
|
124
|
+
* in document order (scripts/styles/meta skipped). Text and computed styles are
|
|
125
|
+
* deliberately excluded, so a ticking clock or animated opacity never reads as a
|
|
126
|
+
* new state, while any mount/unmount/class flip does. ~30ms vs the full
|
|
127
|
+
* computed-style walk it replaces for change detection. */
|
|
128
|
+
/* c8 ignore start */
|
|
129
|
+
function domShape() {
|
|
130
|
+
const SKIP = new Set(['SCRIPT', 'STYLE', 'META', 'LINK', 'NOSCRIPT', 'TEMPLATE']);
|
|
131
|
+
const parts = [];
|
|
132
|
+
const classes = new Set();
|
|
133
|
+
const add = (el) => {
|
|
134
|
+
const cls = el.getAttribute('class') ?? '';
|
|
135
|
+
parts.push(`${el.tagName}.${cls}`);
|
|
136
|
+
for (const c of cls.split(/\s+/))
|
|
137
|
+
if (c)
|
|
138
|
+
classes.add(c);
|
|
139
|
+
};
|
|
140
|
+
// html/body themselves first: state classes there (a theme switch, modal-open)
|
|
141
|
+
// are a common pattern and restyle the whole page without touching any
|
|
142
|
+
// descendant's class — invisible to a descendants-only walk.
|
|
143
|
+
add(document.documentElement);
|
|
144
|
+
add(document.body);
|
|
145
|
+
for (const el of document.body.getElementsByTagName('*')) {
|
|
146
|
+
if (SKIP.has(el.tagName))
|
|
147
|
+
continue;
|
|
148
|
+
add(el);
|
|
149
|
+
}
|
|
150
|
+
return { shape: parts.join('\n'), elements: parts.length, classes: [...classes] };
|
|
151
|
+
}
|
|
152
|
+
/* c8 ignore stop */
|
|
153
|
+
/** Runs in the browser: every class name the page's OWN stylesheets select on —
|
|
154
|
+
* the design's defined vocabulary, read from the parsed CSSOM (inline and
|
|
155
|
+
* same-origin sheets; unreadable cross-origin sheets are skipped). Coverage is
|
|
156
|
+
* checked against this, so "fully covered" means every class the design styles
|
|
157
|
+
* was seen rendered in at least one captured surface. */
|
|
158
|
+
/* c8 ignore start */
|
|
159
|
+
function collectDefinedClasses() {
|
|
160
|
+
const out = new Set();
|
|
161
|
+
const scan = (rules) => {
|
|
162
|
+
if (!rules)
|
|
163
|
+
return;
|
|
164
|
+
for (const rule of rules) {
|
|
165
|
+
const r = rule;
|
|
166
|
+
if (r.selectorText) {
|
|
167
|
+
const re = /\.([A-Za-z_][A-Za-z0-9_-]*)/g;
|
|
168
|
+
for (let m = re.exec(r.selectorText); m; m = re.exec(r.selectorText))
|
|
169
|
+
out.add(m[1]);
|
|
170
|
+
}
|
|
171
|
+
if (r.cssRules)
|
|
172
|
+
scan(r.cssRules);
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
for (const sheet of document.styleSheets) {
|
|
176
|
+
try {
|
|
177
|
+
scan(sheet.cssRules);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
/* cross-origin sheet — not the design's own vocabulary */
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return [...out];
|
|
184
|
+
}
|
|
185
|
+
/* c8 ignore stop */
|
|
186
|
+
/** Structural fingerprint of the page's CURRENT state. Dedup key for surfaces. */
|
|
187
|
+
async function fingerprint(page) {
|
|
188
|
+
const fp = await page.evaluate(domShape);
|
|
189
|
+
return {
|
|
190
|
+
sig: createHash('sha256').update(fp.shape).digest('hex').slice(0, 16),
|
|
191
|
+
elements: fp.elements,
|
|
192
|
+
classes: fp.classes,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/** Wait for the DOM to stop changing (element count stable) — a cheap post-click
|
|
196
|
+
* settle that also covers content painted by a click-triggered fetch. */
|
|
197
|
+
async function settleDom(page, maxMs = 1200) {
|
|
198
|
+
let prev = -1;
|
|
199
|
+
const deadline = Date.now() + maxMs;
|
|
200
|
+
while (Date.now() < deadline) {
|
|
201
|
+
const n = await page.evaluate(() => document.body.getElementsByTagName('*').length);
|
|
202
|
+
if (n === prev)
|
|
203
|
+
return;
|
|
204
|
+
prev = n;
|
|
205
|
+
await page.waitForTimeout(90);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** Wait for an async-rendered app to finish mounting: the DOM stops growing and is
|
|
209
|
+
* non-trivial. Generic — no app-specific selector needed, so a bare crawl of a
|
|
210
|
+
* Babel/React/Vue page that boots after `load` still captures the mounted UI. */
|
|
211
|
+
async function waitSettled(page) {
|
|
212
|
+
await page.waitForLoadState('networkidle').catch(() => { });
|
|
213
|
+
let prev = -1;
|
|
214
|
+
for (let i = 0; i < 40; i++) {
|
|
215
|
+
const n = await page.evaluate(() => document.body.getElementsByTagName('*').length);
|
|
216
|
+
if (n > 5 && n === prev)
|
|
217
|
+
return;
|
|
218
|
+
prev = n;
|
|
219
|
+
await page.waitForTimeout(100);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Load the URL from a clean slate (storage cleared by the init script armed in
|
|
224
|
+
* crawlAndCapture — one navigation, no clear-then-reload) and wait for the app to
|
|
225
|
+
* mount. Pins the viewport to widths[0] so DISCOVERY always happens at one
|
|
226
|
+
* consistent width — captureInPlace sweeps the other widths and would otherwise
|
|
227
|
+
* leave the viewport wherever it finished (e.g. a mobile band where half the
|
|
228
|
+
* controls are hidden).
|
|
229
|
+
*/
|
|
230
|
+
async function gotoFresh(page, opts) {
|
|
231
|
+
await page.setViewportSize({ width: opts.widths[0] ?? 1280, height: opts.height });
|
|
232
|
+
await page.goto(opts.url, { waitUntil: 'load' });
|
|
233
|
+
// Tolerant wait: the generic settle below is the real readiness signal; an
|
|
234
|
+
// optional waitSelector just accelerates it and must not fail the crawl.
|
|
235
|
+
const ready = opts.waitSelector ? page.locator(opts.waitSelector).first() : null;
|
|
236
|
+
if (ready)
|
|
237
|
+
await ready.waitFor({ state: 'visible' }).catch(() => { });
|
|
238
|
+
await waitSettled(page);
|
|
239
|
+
}
|
|
240
|
+
async function perform(page, s) {
|
|
241
|
+
const target = page.locator(s.selector).first();
|
|
242
|
+
if (s.action === 'select-option') {
|
|
243
|
+
await target.selectOption(s.value ?? '');
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
await target.waitFor({ state: 'attached', timeout: 5000 });
|
|
247
|
+
await target.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => { });
|
|
248
|
+
// Dispatch the click IN-PAGE rather than through Playwright's actionability
|
|
249
|
+
// pipeline. `el.click()` fires the app's real (delegated) click handler but
|
|
250
|
+
// skips the stability/visibility/hit-testing waits that make Playwright's own
|
|
251
|
+
// click flake on an opening modal — the intermittent failure that silently
|
|
252
|
+
// dropped deep states on re-drive. We only need to REACH the state to map it.
|
|
253
|
+
const dispatched = await target
|
|
254
|
+
.evaluate((el) => {
|
|
255
|
+
if (el instanceof HTMLElement) {
|
|
256
|
+
el.click();
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
return false;
|
|
260
|
+
})
|
|
261
|
+
.catch(() => false);
|
|
262
|
+
if (!dispatched)
|
|
263
|
+
await target.click({ timeout: 3000 }); // non-HTMLElement (SVG etc.)
|
|
264
|
+
}
|
|
265
|
+
async function replay(page, steps) {
|
|
266
|
+
for (const s of steps) {
|
|
267
|
+
await perform(page, s);
|
|
268
|
+
await settleDom(page);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** Reset to a known state and VERIFY arrival by fingerprint (retry once). A false
|
|
272
|
+
* return means the path won't reproduce right now — the caller abandons the rest
|
|
273
|
+
* of that state's sweep (fail-soft) rather than mis-attributing children. */
|
|
274
|
+
async function resetToState(page, opts, steps, wantSig) {
|
|
275
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
276
|
+
try {
|
|
277
|
+
await gotoFresh(page, opts);
|
|
278
|
+
await replay(page, steps);
|
|
279
|
+
if ((await fingerprint(page)).sig === wantSig)
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
/* retry */
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
/** Capture the page's CURRENT state (already driven to) at every width, resizing
|
|
289
|
+
* in place. Capturing where discovery already stands means a fragile click-path
|
|
290
|
+
* is never replayed a second time — the failure mode of re-driving deep, animated
|
|
291
|
+
* controls. */
|
|
292
|
+
async function captureInPlace(page, key, opts) {
|
|
293
|
+
for (const width of opts.widths) {
|
|
294
|
+
await page.setViewportSize({ width, height: opts.height });
|
|
295
|
+
const requests = trackInflightRequests(page);
|
|
296
|
+
try {
|
|
297
|
+
const map = await captureStyleMap(page, {
|
|
298
|
+
ignore: opts.ignore,
|
|
299
|
+
pendingRequests: requests.pending,
|
|
300
|
+
metadata: { surfaceKey: key },
|
|
301
|
+
});
|
|
302
|
+
const stem = path.join(opts.out, `${key}@${width}`);
|
|
303
|
+
saveStyleMap(`${stem}.json.gz`, map);
|
|
304
|
+
if (opts.screenshots)
|
|
305
|
+
await page.screenshot({ path: `${stem}.png`, fullPage: true, animations: 'disabled' });
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
requests.dispose();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
/** Identity of a state for the changer registry: the click-path that defines it. */
|
|
313
|
+
function stateKey(steps) {
|
|
314
|
+
return steps.map((s) => s.selector + (s.value ?? '')).join(' → ');
|
|
315
|
+
}
|
|
316
|
+
/** Record a newly-found surface, capture it in place (page is already there), and
|
|
317
|
+
* queue it for its own sweep. Streams progress via onSurface. */
|
|
318
|
+
async function record(page, opts, newPath, depth, fp, st) {
|
|
319
|
+
const key = deriveKey(newPath, st.used);
|
|
320
|
+
const surface = { key, depth, path: newPath, elements: fp.elements };
|
|
321
|
+
st.surfaces.push(surface);
|
|
322
|
+
st.queue.push({ path: newPath, depth, sig: fp.sig });
|
|
323
|
+
for (const c of fp.classes)
|
|
324
|
+
st.classes.add(c);
|
|
325
|
+
let ok = true;
|
|
326
|
+
try {
|
|
327
|
+
await captureInPlace(page, key, opts);
|
|
328
|
+
st.captured++;
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
st.failed.push(key);
|
|
332
|
+
ok = false;
|
|
333
|
+
}
|
|
334
|
+
opts.onSurface?.(surface, ok);
|
|
335
|
+
}
|
|
336
|
+
/** Click one candidate where the page stands; classify the outcome. */
|
|
337
|
+
async function tryInPlace(page, c) {
|
|
338
|
+
const startUrl = pathAndSearch(page.url());
|
|
339
|
+
const before = (await fingerprint(page)).sig;
|
|
340
|
+
try {
|
|
341
|
+
await perform(page, c);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return 'failed';
|
|
345
|
+
}
|
|
346
|
+
await settleDom(page);
|
|
347
|
+
if (pathAndSearch(page.url()) !== startUrl)
|
|
348
|
+
return 'navigated';
|
|
349
|
+
return (await fingerprint(page)).sig === before ? 'noop' : 'changed';
|
|
350
|
+
}
|
|
351
|
+
/** Drive one candidate from where the page stands. Returns whether the page is
|
|
352
|
+
* still in the swept state (no-op click) and whether the action was a skip. */
|
|
353
|
+
async function driveCandidate(page, opts, entry, c, st) {
|
|
354
|
+
const outcome = await tryInPlace(page, c);
|
|
355
|
+
if (outcome === 'noop')
|
|
356
|
+
return { inState: true, skipped: false }; // still in the state — no reset needed
|
|
357
|
+
if (outcome === 'failed' || outcome === 'navigated')
|
|
358
|
+
return { inState: false, skipped: true };
|
|
359
|
+
// Register the changer for family retry — persistence checked in the state its
|
|
360
|
+
// own click produced (a tab survives its switch; an approve row consumes itself).
|
|
361
|
+
const persists = await page
|
|
362
|
+
.locator(c.selector)
|
|
363
|
+
.first()
|
|
364
|
+
.isVisible()
|
|
365
|
+
.catch(() => false);
|
|
366
|
+
const from = stateKey(entry.path);
|
|
367
|
+
const list = st.changersFrom.get(from) ?? [];
|
|
368
|
+
if (!list.some((x) => x.c.selector === c.selector))
|
|
369
|
+
list.push({ c, persists });
|
|
370
|
+
st.changersFrom.set(from, list);
|
|
371
|
+
const fp = await fingerprint(page);
|
|
372
|
+
if (st.seen.has(fp.sig))
|
|
373
|
+
return { inState: false, skipped: false }; // same surface reached another way
|
|
374
|
+
st.seen.add(fp.sig);
|
|
375
|
+
const step = {
|
|
376
|
+
action: c.action,
|
|
377
|
+
selector: c.selector,
|
|
378
|
+
label: c.label,
|
|
379
|
+
reason: c.reason,
|
|
380
|
+
...(c.value ? { value: c.value } : {}),
|
|
381
|
+
};
|
|
382
|
+
await record(page, opts, [...entry.path, step], entry.depth + 1, fp, st);
|
|
383
|
+
return { inState: false, skipped: false };
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Sweep one state: drive every not-yet-tried control reachable from it, IN PLACE.
|
|
387
|
+
* A no-op click leaves the page in the state, so the sweep just continues; only a
|
|
388
|
+
* state-changing click (new surface, dup, navigation, or failure) pays a verified
|
|
389
|
+
* reset before the next candidate.
|
|
390
|
+
*/
|
|
391
|
+
/** The family-retry list for a state: its parent's persistent mode-switchers that
|
|
392
|
+
* are visible right now — minus the step that created this state itself. */
|
|
393
|
+
function familyRetries(entry, all, st) {
|
|
394
|
+
if (entry.path.length === 0)
|
|
395
|
+
return [];
|
|
396
|
+
const parentKey = stateKey(entry.path.slice(0, -1));
|
|
397
|
+
const ownSelector = entry.path[entry.path.length - 1].selector;
|
|
398
|
+
const visibleNow = new Set(all.map((c) => c.selector));
|
|
399
|
+
return (st.changersFrom.get(parentKey) ?? [])
|
|
400
|
+
.filter((x) => x.persists && x.c.selector !== ownSelector && visibleNow.has(x.c.selector))
|
|
401
|
+
.map((x) => x.c);
|
|
402
|
+
}
|
|
403
|
+
async function sweepState(page, opts, entry, st) {
|
|
404
|
+
if (!(await resetToState(page, opts, entry.path, entry.sig)))
|
|
405
|
+
return { tried: 0, skipped: 0 };
|
|
406
|
+
const all = await page.evaluate(collectClickable).catch(() => []);
|
|
407
|
+
// Fresh controls first (already-driven global chrome would otherwise starve a
|
|
408
|
+
// deep surface's own controls; the throttle applies to fresh ones), then the
|
|
409
|
+
// parent's persistent mode-switchers re-tried in THIS sibling mode.
|
|
410
|
+
const fresh = all.filter((c) => !st.tried.has(c.selector)).slice(0, opts.maxActionsPerState);
|
|
411
|
+
const work = [
|
|
412
|
+
...fresh.map((c) => ({ c, retry: false })),
|
|
413
|
+
...familyRetries(entry, all, st).map((c) => ({ c, retry: true })),
|
|
414
|
+
];
|
|
415
|
+
let tried = 0;
|
|
416
|
+
let skipped = 0;
|
|
417
|
+
let inState = true;
|
|
418
|
+
for (const { c, retry } of work) {
|
|
419
|
+
if (st.surfaces.length >= opts.maxStates)
|
|
420
|
+
break;
|
|
421
|
+
if (!inState) {
|
|
422
|
+
if (!(await resetToState(page, opts, entry.path, entry.sig)))
|
|
423
|
+
break; // abandon rest, fail-soft
|
|
424
|
+
inState = true;
|
|
425
|
+
}
|
|
426
|
+
if (!retry)
|
|
427
|
+
st.tried.add(c.selector);
|
|
428
|
+
if (c.unsafe) {
|
|
429
|
+
skipped++;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
tried++;
|
|
433
|
+
const r = await driveCandidate(page, opts, entry, c, st);
|
|
434
|
+
inState = r.inState;
|
|
435
|
+
if (r.skipped)
|
|
436
|
+
skipped++;
|
|
437
|
+
}
|
|
438
|
+
return { tried, skipped };
|
|
439
|
+
}
|
|
440
|
+
/** Depth-first discovery + in-place capture of every reachable surface. Depth-first
|
|
441
|
+
* so a surface's OWN sub-states (a modal's tab → its toggles) are mapped while the
|
|
442
|
+
* branch is fresh; with no budget, order affects time-to-depth, not coverage. */
|
|
443
|
+
async function discover(page, opts) {
|
|
444
|
+
fs.mkdirSync(opts.out, { recursive: true });
|
|
445
|
+
await gotoFresh(page, opts);
|
|
446
|
+
const defined = await page.evaluate(collectDefinedClasses).catch(() => []);
|
|
447
|
+
const fp = await fingerprint(page);
|
|
448
|
+
const st = {
|
|
449
|
+
seen: new Set([fp.sig]),
|
|
450
|
+
used: new Set(),
|
|
451
|
+
tried: new Set(),
|
|
452
|
+
changersFrom: new Map(),
|
|
453
|
+
classes: new Set(),
|
|
454
|
+
surfaces: [],
|
|
455
|
+
queue: [],
|
|
456
|
+
captured: 0,
|
|
457
|
+
failed: [],
|
|
458
|
+
};
|
|
459
|
+
await record(page, opts, [], 0, fp, st);
|
|
460
|
+
let actionsTried = 0;
|
|
461
|
+
let skipped = 0;
|
|
462
|
+
while (st.queue.length > 0 && st.surfaces.length < opts.maxStates) {
|
|
463
|
+
const entry = st.queue.pop(); // LIFO → depth-first
|
|
464
|
+
if (entry.depth >= opts.maxDepth)
|
|
465
|
+
continue;
|
|
466
|
+
const r = await sweepState(page, opts, entry, st);
|
|
467
|
+
actionsTried += r.tried;
|
|
468
|
+
skipped += r.skipped;
|
|
469
|
+
}
|
|
470
|
+
const missing = defined.filter((c) => !st.classes.has(c)).sort();
|
|
471
|
+
return {
|
|
472
|
+
surfaces: st.surfaces,
|
|
473
|
+
actionsTried,
|
|
474
|
+
skipped,
|
|
475
|
+
captured: st.captured,
|
|
476
|
+
failed: st.failed,
|
|
477
|
+
coverage: { defined: defined.length, rendered: defined.length - missing.length, missing },
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Crawl `opts.url` and capture every reachable surface at every width — runs to
|
|
482
|
+
* natural termination by default. Returns the surfaces mapped (with the click-path
|
|
483
|
+
* that reached each), how many actions were tried/skipped, and captured/failed.
|
|
484
|
+
*/
|
|
485
|
+
export async function crawlAndCapture(page, opts) {
|
|
486
|
+
if (opts.resetStorage) {
|
|
487
|
+
// Clear storage before the app's code runs on EVERY load, so each gotoFresh is
|
|
488
|
+
// a clean slate in one navigation (no clear-then-reload round trip).
|
|
489
|
+
await page.addInitScript(() => {
|
|
490
|
+
try {
|
|
491
|
+
localStorage.clear();
|
|
492
|
+
sessionStorage.clear();
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
/* storage unavailable (e.g. file://) — ignore */
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
return discover(page, opts);
|
|
500
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
|
|
2
|
+
export { captureUrlToDir, runCaptureUrl, parseCaptureUrlArgs, UsageError } from './capture-url.js';
|
|
3
|
+
export type { CaptureUrlOptions, CaptureUrlResult } from './capture-url.js';
|
|
4
|
+
export { crawlAndCapture, CRAWL_DEFAULTS } from './crawl-surfaces.js';
|
|
5
|
+
export type { SurfaceCrawlOptions, CrawlReport, CrawlCoverage, CrawledSurface, CrawlStep } from './crawl-surfaces.js';
|
|
2
6
|
export type { StyleMap, CaptureOptions, CaptureMetadata, ElementEntry, LiveRegionCandidate, CapturedOverlay, Rect, } from './capture.js';
|
|
3
7
|
export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
|
|
4
8
|
export type { Surface, SurfaceLiveState, SurfaceVariant, PopupCaptureOptions, DefineOptions, CrawlOptions, } from './runner.js';
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
|
|
2
|
+
export { captureUrlToDir, runCaptureUrl, parseCaptureUrlArgs, UsageError } from './capture-url.js';
|
|
3
|
+
export { crawlAndCapture, CRAWL_DEFAULTS } from './crawl-surfaces.js';
|
|
2
4
|
export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
|
|
3
5
|
export { coverageGaps } from './coverage.js';
|
|
4
6
|
export { detectViewportWidths, mediaTextWidthBoundaries, widthsFromBoundaries } from './breakpoints.js';
|
package/dist/map-store.js
CHANGED
|
@@ -10,6 +10,7 @@ export const DEFAULT_MAP_LABEL = 'current';
|
|
|
10
10
|
export const DEFAULT_MAP_STORE_BRANCH = 'styleproof-maps';
|
|
11
11
|
export const DEFAULT_REMOTE = 'origin';
|
|
12
12
|
export const MAP_MANIFEST = 'styleproof-manifest.json';
|
|
13
|
+
const GENERATED_DIRTY_ALLOWLIST = new Set(['next-env.d.ts']);
|
|
13
14
|
export class MapStoreError extends Error {
|
|
14
15
|
}
|
|
15
16
|
function runGit(cwd, args, maxBuffer = 1 << 28) {
|
|
@@ -112,7 +113,14 @@ export function refSha(ref, cwd = process.cwd()) {
|
|
|
112
113
|
return sha;
|
|
113
114
|
}
|
|
114
115
|
export function workingTreeDirty(cwd = process.cwd()) {
|
|
115
|
-
|
|
116
|
+
const r = runGit(cwd, ['status', '--porcelain']);
|
|
117
|
+
const status = r.status === 0 ? r.stdout.trimEnd() : '';
|
|
118
|
+
if (!status)
|
|
119
|
+
return false;
|
|
120
|
+
return status.split(/\r?\n/).some((line) => {
|
|
121
|
+
const file = line.slice(3).trim();
|
|
122
|
+
return file && !GENERATED_DIRTY_ALLOWLIST.has(file);
|
|
123
|
+
});
|
|
116
124
|
}
|
|
117
125
|
export function remoteExists(remote = DEFAULT_REMOTE, cwd = process.cwd()) {
|
|
118
126
|
return runGit(cwd, ['remote', 'get-url', remote], 1 << 20).status === 0;
|
package/dist/report.js
CHANGED
|
@@ -8,8 +8,7 @@ import { describeChange, tokenIndex, toHex, trackCount } from './describe.js';
|
|
|
8
8
|
// through the package's report module rather than a deep path.
|
|
9
9
|
export { describeChange, colorName, tokenIndex, toHex } from './describe.js';
|
|
10
10
|
// Hidden marker appended to a new-surface heading. Invisible in rendered
|
|
11
|
-
// markdown; lets the PR-comment layer
|
|
12
|
-
// surface (vs the required box on a real change), so new surfaces never gate.
|
|
11
|
+
// markdown; lets the PR-comment layer recognize one-sided surfaces.
|
|
13
12
|
const NEW_SURFACE_MARKER = '<!-- styleproof-new -->';
|
|
14
13
|
const rectToBox = (r) => ({ x: r[0], y: r[1], w: r[2], h: r[3] });
|
|
15
14
|
const pad = (b, by) => ({ x: b.x - by, y: b.y - by, w: b.w + 2 * by, h: b.h + 2 * by });
|
|
@@ -910,8 +909,8 @@ export function generateStyleMapReport(opts) {
|
|
|
910
909
|
if (missing.length > 0) {
|
|
911
910
|
if (changeGroups.length > 0)
|
|
912
911
|
md.push('');
|
|
913
|
-
md.push(`🆕 **${missing.length} new surface(s)** captured with no baseline to compare — shown below for
|
|
914
|
-
`
|
|
912
|
+
md.push(`🆕 **${missing.length} new surface(s)** captured with no baseline to compare — shown below for review. ` +
|
|
913
|
+
`Approve them before they become the baseline.`);
|
|
915
914
|
}
|
|
916
915
|
}
|
|
917
916
|
if (volatileCount > 0) {
|
|
@@ -1037,8 +1036,7 @@ export function generateStyleMapReport(opts) {
|
|
|
1037
1036
|
json.push(surfaceJson);
|
|
1038
1037
|
}
|
|
1039
1038
|
// New surfaces: present on only one side, so there's nothing to diff. Show the
|
|
1040
|
-
// captured side as a single screenshot
|
|
1041
|
-
// PR comment can attach an OPTIONAL approval box — these never gate the check.
|
|
1039
|
+
// captured side as a single screenshot and mark the heading for the PR comment.
|
|
1042
1040
|
for (const p of missing) {
|
|
1043
1041
|
const side = p.sd.missing === 'before' ? 'after' : 'before';
|
|
1044
1042
|
const srcDir = side === 'after' ? afterDir : beforeDir;
|
|
@@ -1058,7 +1056,7 @@ export function generateStyleMapReport(opts) {
|
|
|
1058
1056
|
else {
|
|
1059
1057
|
md.push('', `_Captured only in the **${side}** set; no screenshot saved (run captures with \`screenshots: true\`)._`);
|
|
1060
1058
|
}
|
|
1061
|
-
md.push('', `_No baseline to compare against — this surface is new
|
|
1059
|
+
md.push('', `_No baseline to compare against — this surface is new. Review and approve it before it becomes part of the baseline._`);
|
|
1062
1060
|
json.push(surfaceJson);
|
|
1063
1061
|
}
|
|
1064
1062
|
md.push(...contentSection.md);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.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",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"bin": {
|
|
39
39
|
"styleproof-init": "./bin/styleproof-init.mjs",
|
|
40
40
|
"styleproof-map": "./bin/styleproof-map.mjs",
|
|
41
|
+
"styleproof-capture": "./bin/styleproof-capture.mjs",
|
|
41
42
|
"styleproof-diff": "./bin/styleproof-diff.mjs",
|
|
42
43
|
"styleproof-report": "./bin/styleproof-report.mjs",
|
|
43
44
|
"styleproof-variants": "./bin/styleproof-variants.mjs"
|