styleproof 2.1.0 → 2.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 +41 -0
- package/README.md +32 -0
- package/dist/capture.d.ts +25 -0
- package/dist/capture.js +81 -26
- package/dist/crawl.d.ts +58 -0
- package/dist/crawl.js +94 -0
- package/dist/diff.d.ts +4 -0
- package/dist/diff.js +30 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/report.js +54 -18
- package/dist/runner.d.ts +52 -1
- package/dist/runner.js +99 -15
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,47 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.3.0] - 2026-06-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`defineCrawlCapture` — discover surfaces by crawling rendered links.**
|
|
15
|
+
`discoverNextRoutes` reads the filesystem, so it only sees one surface per
|
|
16
|
+
`app/**` page — blind to a single-route SPA whose views are query params
|
|
17
|
+
(`/?tab=overview`) or client-routed, which exist only in the rendered nav as its
|
|
18
|
+
links. `defineCrawlCapture({ from, match, widths, dir })` loads a root URL, reads
|
|
19
|
+
its same-origin `<a href>`s (filtered by `match`), and captures each as a surface
|
|
20
|
+
keyed from its URL (`/?tab=overview` → `overview`; override with `key`). The
|
|
21
|
+
surface set _is_ the nav, so there's no hand-maintained `surfaces` list to drift
|
|
22
|
+
out of sync. The app just has to render its nav as real links — a button-only nav
|
|
23
|
+
exposes nothing to crawl. Replay, self-check and clock-freeze behave exactly as
|
|
24
|
+
for explicit surfaces. Also exported: `selectCrawlLinks` / `defaultLinkKey` (the
|
|
25
|
+
pure link-selection helpers) and the `CrawlOptions` / `CrawlLink` / `LinkMatch`
|
|
26
|
+
types.
|
|
27
|
+
|
|
28
|
+
## [2.2.0] - 2026-06-23
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **Newly-added elements now report their full resting computed style**, not just
|
|
33
|
+
interaction-state deltas. Previously an added element surfaced only its
|
|
34
|
+
`:hover`/`:focus` changes (the diff short-circuited added elements before the
|
|
35
|
+
style loop); its background, padding, font, radius, etc. were captured but never
|
|
36
|
+
shown. The diff now emits the new element's full style as `(unset) → value`
|
|
37
|
+
findings and the report renders them value-only (no bogus "Before" column), in
|
|
38
|
+
both the PR report and the `styleproof-diff` CLI. The element already gated via
|
|
39
|
+
its `added` finding, so this enriches detail without changing what gates.
|
|
40
|
+
- **`captureComponent` (opt-in, default off): surface the React component + props**
|
|
41
|
+
behind each element. With it on, capture reads the React fiber in-page
|
|
42
|
+
(`__reactFiber$*`/`__reactProps$*` on React 17+, `__reactInternalInstance$*` on
|
|
43
|
+
≤16) to record the component display name and a sanitized subset of its props
|
|
44
|
+
(primitives only; `children`/handlers/objects dropped) on `ElementEntry.component`.
|
|
45
|
+
The report names it — `React component: Button (variant=primary, size=sm)` —
|
|
46
|
+
instead of a bare `<button>`. **Advisory only**, exactly like the content layer:
|
|
47
|
+
never fed to the certification diff or its blocking counts, so captures stay
|
|
48
|
+
deterministic. Names are mangled in minified prod builds, so it's most useful
|
|
49
|
+
against dev/non-minified output; a no-op on non-React pages.
|
|
50
|
+
|
|
10
51
|
## [2.1.0] - 2026-06-23
|
|
11
52
|
|
|
12
53
|
### Added
|
package/README.md
CHANGED
|
@@ -64,6 +64,21 @@ defineStyleMapCapture({
|
|
|
64
64
|
|
|
65
65
|
`discoverNextRoutes(cwd?)` reads the filesystem only (route groups `(group)` and `@slots` stripped, `[param]`/`[...catchall]` flagged `dynamic`) — a heuristic, not a router; edit the generated spec for exotic routing. For any other framework, point `expected` at your own route registry as above.
|
|
66
66
|
|
|
67
|
+
**Single-route SPAs: crawl the nav instead.** Filesystem discovery can't see a surface that isn't a page — a tab SPA where every view is `/?tab=overview` on one `app/page.tsx`, or anything client-routed. There the surfaces exist only in the rendered nav, as its links. `defineCrawlCapture` discovers them at run time: it loads a root URL, reads its same-origin `<a href>`s, and captures each — so the surface set _is_ the nav, with no list to hand-maintain (and so none to drift).
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { defineCrawlCapture } from 'styleproof';
|
|
71
|
+
|
|
72
|
+
defineCrawlCapture({
|
|
73
|
+
from: '/', // crawl the app root for links
|
|
74
|
+
match: /\?tab=/, // keep just the tab views (omit to take every same-origin link)
|
|
75
|
+
widths: [1440, 1024, 768],
|
|
76
|
+
dir: process.env.STYLEMAP_DIR,
|
|
77
|
+
});
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Each discovered link becomes a surface keyed by its URL (`/?tab=overview` → `overview`; pass `key` for a different scheme). The app only has to render its nav as real `<a href>` links — a button-only nav (`<button onClick>`) exposes nothing to crawl. Replay, self-check and clock-freeze behave exactly as for explicit surfaces; one Playwright test runs the whole sweep (the link set isn't known until the page renders).
|
|
81
|
+
|
|
67
82
|
## What a report looks like
|
|
68
83
|
|
|
69
84
|
One change — the hero CTA recoloured cyan → amber — appears as a single section in the report: a side-by-side before/after cropped screenshot, a one-line summary, then the exact property change folded under a toggle.
|
|
@@ -228,6 +243,23 @@ The report then carries a separate **📝 Content changes (advisory)** section:
|
|
|
228
243
|
|
|
229
244
|
Notes: only an element's _own_ text is recorded (so a parent and child never double-report the same string); text churn in a live region is auto-excluded by the same settle pass that guards styles; and the certification CLI (`styleproof-diff`) is deliberately left content-blind.
|
|
230
245
|
|
|
246
|
+
## Optional: React component layer (advisory)
|
|
247
|
+
|
|
248
|
+
For a React app, knowing _which component_ rendered an element is often the fastest way to read a change. Off by default, opt in with `captureComponent`:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
// styleproof.spec.ts — record the React component + props behind each element
|
|
252
|
+
defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR, captureComponent: true });
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Capture reads the React fiber in-page (`__reactFiber$*`/`__reactProps$*` on React 17+, `__reactInternalInstance$*` on ≤16) and records the component display name plus a **sanitized** subset of its props (primitives only — `children`, handlers, and objects are dropped) on `ElementEntry.component`. The report then names the element — **`React component: Button (variant=primary, size=sm)`** — instead of showing a bare `<button>`.
|
|
256
|
+
|
|
257
|
+
Like the content layer it is **advisory**: never fed to the certification diff or the gate, so captures stay deterministic. Component names are mangled in minified production builds, so it's most useful against a dev / non-minified target; on a non-React page the fiber keys are absent and the field is simply omitted.
|
|
258
|
+
|
|
259
|
+
## Newly-added elements show their full style
|
|
260
|
+
|
|
261
|
+
When a PR **adds** an element, StyleProof now reports its **full resting computed style** (background, padding, font, radius, …), value-only, in addition to any interaction-state deltas — previously an added element surfaced only its `:hover`/`:focus` changes. The new element already gates via its `added` finding; this only enriches what you see, in both the report and the `styleproof-diff` CLI.
|
|
262
|
+
|
|
231
263
|
## Reference
|
|
232
264
|
|
|
233
265
|
**Action `BenSheridanEdwards/StyleProof@v2`** — key inputs:
|
package/dist/capture.d.ts
CHANGED
|
@@ -42,6 +42,18 @@ export type ElementEntry = {
|
|
|
42
42
|
* advisory, not a computed-style outcome. See README "Optional: content layer".
|
|
43
43
|
*/
|
|
44
44
|
text?: string;
|
|
45
|
+
/**
|
|
46
|
+
* The React component that rendered this element, and a sanitized subset of its
|
|
47
|
+
* props — present only when capture ran with `captureComponent: true` (opt-in,
|
|
48
|
+
* off by default). Extracted in-page from the React fiber. ADVISORY: never fed
|
|
49
|
+
* to the certification diff or its counts (like {@link ElementEntry.text}); it
|
|
50
|
+
* only enriches the report so a reviewer sees `Button (variant=primary)` rather
|
|
51
|
+
* than a bare `<button>`. Best in dev/non-minified builds (prod mangles names).
|
|
52
|
+
*/
|
|
53
|
+
component?: {
|
|
54
|
+
name: string;
|
|
55
|
+
props?: Record<string, string>;
|
|
56
|
+
};
|
|
45
57
|
};
|
|
46
58
|
export type StyleMap = {
|
|
47
59
|
defaults: Record<string, Props>;
|
|
@@ -137,6 +149,19 @@ export type CaptureOptions = {
|
|
|
137
149
|
* guards styles, since text now participates in change detection.
|
|
138
150
|
*/
|
|
139
151
|
captureText?: boolean;
|
|
152
|
+
/**
|
|
153
|
+
* Opt-in React layer (default OFF). When true, each element records the React
|
|
154
|
+
* component that rendered it (display name) and a sanitized subset of its props
|
|
155
|
+
* on {@link ElementEntry.component}, read in-page from the React fiber
|
|
156
|
+
* (`__reactFiber$*`/`__reactProps$*` on React 17+, `__reactInternalInstance$*`
|
|
157
|
+
* on ≤16). Lets the report name `Button (variant=primary)` instead of a bare
|
|
158
|
+
* `<button>`. ADVISORY — like `captureText` it never feeds the certification
|
|
159
|
+
* diff or its blocking counts. Only primitive props (string/number/boolean) are
|
|
160
|
+
* kept (children/handlers/objects dropped); names are mangled in minified prod
|
|
161
|
+
* builds, so this is most useful against dev/non-minified output. No-op on
|
|
162
|
+
* non-React pages (fiber keys absent → field omitted).
|
|
163
|
+
*/
|
|
164
|
+
captureComponent?: boolean;
|
|
140
165
|
/**
|
|
141
166
|
* Advanced/internal: a getter for the count of in-flight data requests, supplied by
|
|
142
167
|
* a tracker ({@link trackInflightRequests}) armed BEFORE navigation so the page's own
|
package/dist/capture.js
CHANGED
|
@@ -30,15 +30,12 @@ const FRAMEWORK_IGNORE = [
|
|
|
30
30
|
export function isUnder(path, roots) {
|
|
31
31
|
return roots.some((r) => path === r || path.startsWith(r + ' > '));
|
|
32
32
|
}
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
const PSEUDOS = ['::before', '::after', '::marker', '::placeholder'];
|
|
40
|
-
const skipSel = ignore.length ? ignore.map((s) => `${s}, ${s} *`).join(', ') : '';
|
|
41
|
-
const pathOf = (el) => {
|
|
33
|
+
// Defines the structural-path helper on `window` once so the two functions
|
|
34
|
+
// serialized into the page (capturePage, markInteractiveElements) share ONE
|
|
35
|
+
// implementation — page.evaluate can't reference a module-scope helper, so the
|
|
36
|
+
// alternative is an identical copy in each. Injected at the top of captureStyleMap.
|
|
37
|
+
function injectPathOf() {
|
|
38
|
+
window.__spPathOf = (el) => {
|
|
42
39
|
if (el === document.documentElement)
|
|
43
40
|
return 'html';
|
|
44
41
|
if (el === document.body)
|
|
@@ -54,6 +51,16 @@ function capturePage({ ignore, motionOnly, captureText }) {
|
|
|
54
51
|
}
|
|
55
52
|
return 'body > ' + parts.join(' > ');
|
|
56
53
|
};
|
|
54
|
+
}
|
|
55
|
+
// Serialized into the browser by page.evaluate; cannot call module helpers.
|
|
56
|
+
// Pre-existing, grandfathered in the health baseline; the content layer adds
|
|
57
|
+
// one small captureText block, not new structure.
|
|
58
|
+
// fallow-ignore-next-line complexity
|
|
59
|
+
function capturePage({ ignore, motionOnly, captureText, captureComponent }) {
|
|
60
|
+
const MOTION = /^(transition|animation)/;
|
|
61
|
+
const PSEUDOS = ['::before', '::after', '::marker', '::placeholder'];
|
|
62
|
+
const skipSel = ignore.length ? ignore.map((s) => `${s}, ${s} *`).join(', ') : '';
|
|
63
|
+
const pathOf = window.__spPathOf;
|
|
57
64
|
// Per-tag (and per-tag-per-pseudo) UA defaults from a stylesheet-free iframe,
|
|
58
65
|
// used to prune the maps. A pseudo-element's UA defaults are NOT the host
|
|
59
66
|
// element's defaults, so they are measured and cached separately under a
|
|
@@ -96,6 +103,53 @@ function capturePage({ ignore, motionOnly, captureText }) {
|
|
|
96
103
|
}
|
|
97
104
|
return o;
|
|
98
105
|
};
|
|
106
|
+
// Display name from a fiber `type`: function/class, or a forwardRef/memo wrapper
|
|
107
|
+
// object; '' for host fibers (string type), which keeps the walk going upward.
|
|
108
|
+
const nameOfType = (t) => {
|
|
109
|
+
if (typeof t === 'function') {
|
|
110
|
+
const f = t;
|
|
111
|
+
return f.displayName || f.name || '';
|
|
112
|
+
}
|
|
113
|
+
if (t && typeof t === 'object') {
|
|
114
|
+
const w = t;
|
|
115
|
+
const inner = w.render || w.type;
|
|
116
|
+
return w.displayName || inner?.displayName || inner?.name || '';
|
|
117
|
+
}
|
|
118
|
+
return '';
|
|
119
|
+
};
|
|
120
|
+
// Keep only primitive props (drop children/className/style/handlers/objects),
|
|
121
|
+
// capped — advisory, so never anything that could be huge or non-serializable.
|
|
122
|
+
const sanitizeProps = (mp) => {
|
|
123
|
+
const props = {};
|
|
124
|
+
for (const k of Object.keys(mp)) {
|
|
125
|
+
if (k === 'children' || k === 'className' || k === 'style')
|
|
126
|
+
continue;
|
|
127
|
+
const ty = typeof mp[k];
|
|
128
|
+
if (ty === 'string' || ty === 'number' || ty === 'boolean')
|
|
129
|
+
props[k] = String(mp[k]).slice(0, 80);
|
|
130
|
+
}
|
|
131
|
+
return props;
|
|
132
|
+
};
|
|
133
|
+
const reactComponent = (el) => {
|
|
134
|
+
const fiberKey = Object.keys(el).find((k) => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$'));
|
|
135
|
+
if (!fiberKey)
|
|
136
|
+
return undefined;
|
|
137
|
+
let fiber = el[fiberKey] ?? null;
|
|
138
|
+
for (let hops = 0; fiber && hops < 30; fiber = fiber.return, hops++) {
|
|
139
|
+
const name = nameOfType(fiber.type);
|
|
140
|
+
if (!name || name === 'Symbol(react.fragment)')
|
|
141
|
+
continue;
|
|
142
|
+
const out = { name };
|
|
143
|
+
const mp = fiber.memoizedProps;
|
|
144
|
+
if (mp && typeof mp === 'object') {
|
|
145
|
+
const props = sanitizeProps(mp);
|
|
146
|
+
if (Object.keys(props).length)
|
|
147
|
+
out.props = props;
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
};
|
|
99
153
|
const elements = {};
|
|
100
154
|
const all = [document.documentElement, document.body, ...document.querySelectorAll('body *')];
|
|
101
155
|
// Surface untraversed shadow roots and same-origin iframes so a refactor
|
|
@@ -147,6 +201,16 @@ function capturePage({ ignore, motionOnly, captureText }) {
|
|
|
147
201
|
if (t)
|
|
148
202
|
entry.text = t;
|
|
149
203
|
}
|
|
204
|
+
if (captureComponent) {
|
|
205
|
+
try {
|
|
206
|
+
const comp = reactComponent(el);
|
|
207
|
+
if (comp)
|
|
208
|
+
entry.component = comp;
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
// non-React node or inaccessible fiber — component stays absent
|
|
212
|
+
}
|
|
213
|
+
}
|
|
150
214
|
}
|
|
151
215
|
for (const ps of PSEUDOS) {
|
|
152
216
|
if (ps === '::marker' && getComputedStyle(el).display !== 'list-item')
|
|
@@ -233,22 +297,7 @@ const STATE_ID_ATTR = 'data-styleproof-state-id';
|
|
|
233
297
|
* but different ordering, which produces phantom forced-state diffs.
|
|
234
298
|
*/
|
|
235
299
|
function markInteractiveElements({ selector, skipSel, attr }) {
|
|
236
|
-
const pathOf =
|
|
237
|
-
if (el === document.documentElement)
|
|
238
|
-
return 'html';
|
|
239
|
-
if (el === document.body)
|
|
240
|
-
return 'body';
|
|
241
|
-
const parts = [];
|
|
242
|
-
let n = el;
|
|
243
|
-
while (n && n !== document.body) {
|
|
244
|
-
const parent = n.parentElement;
|
|
245
|
-
if (!parent)
|
|
246
|
-
break;
|
|
247
|
-
parts.unshift(`${n.tagName.toLowerCase()}:nth-child(${Array.prototype.indexOf.call(parent.children, n) + 1})`);
|
|
248
|
-
n = parent;
|
|
249
|
-
}
|
|
250
|
-
return 'body > ' + parts.join(' > ');
|
|
251
|
-
};
|
|
300
|
+
const pathOf = window.__spPathOf;
|
|
252
301
|
let i = 0;
|
|
253
302
|
return [...document.querySelectorAll(selector)].flatMap((el) => {
|
|
254
303
|
if (skipSel && el.matches(skipSel))
|
|
@@ -497,6 +546,7 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
497
546
|
const maxInteractive = options.maxInteractive ?? 800;
|
|
498
547
|
const stabilize = options.stabilize ?? true;
|
|
499
548
|
const captureText = options.captureText ?? false;
|
|
549
|
+
const captureComponent = options.captureComponent ?? false;
|
|
500
550
|
// Neutralise real focus the same way FREEZE_CSS neutralises motion: blur
|
|
501
551
|
// whatever element holds focus so every read below is the no-interaction
|
|
502
552
|
// resting state. Real :focus is nondeterministic across runs (autofocus, late
|
|
@@ -507,6 +557,11 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
507
557
|
// deterministic" failure). Interaction states are certified deterministically
|
|
508
558
|
// via CDP forcePseudoState below, never via whatever happened to be focused.
|
|
509
559
|
await page.evaluate(() => document.activeElement?.blur?.());
|
|
560
|
+
// Inject the structural-path helper once so both capturePage and
|
|
561
|
+
// markInteractiveElements (each serialized into the page by page.evaluate, which
|
|
562
|
+
// can't share a module-scope function) call ONE definition — no duplicated source.
|
|
563
|
+
// It persists on `window` for every evaluate below (no navigation between them).
|
|
564
|
+
await page.evaluate(injectPathOf);
|
|
510
565
|
// Motion longhands first (FREEZE_CSS would null them), then everything else.
|
|
511
566
|
// Motion never carries text — it's a settled end state of declared motion.
|
|
512
567
|
const motion = await page.evaluate(capturePage, { ignore, motionOnly: true, captureText: false });
|
|
@@ -516,7 +571,7 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
516
571
|
// (a live stream/ticker) to exclude — animations are frozen above, so only
|
|
517
572
|
// real content/layout churn lands here.
|
|
518
573
|
const volatile = await detectVolatile(page, ignore, stabilize, captureText, options.pendingRequests);
|
|
519
|
-
const base = await page.evaluate(capturePage, { ignore, motionOnly: false, captureText });
|
|
574
|
+
const base = await page.evaluate(capturePage, { ignore, motionOnly: false, captureText, captureComponent });
|
|
520
575
|
dropVolatile(base.elements, volatile);
|
|
521
576
|
warnUntraversed(base.shadowHosts, base.sameOriginFrames);
|
|
522
577
|
mergeMotion(base.elements, motion.elements);
|
package/dist/crawl.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link-crawl surface discovery, for apps whose surfaces aren't filesystem routes.
|
|
3
|
+
*
|
|
4
|
+
* {@link discoverNextRoutes} reads the filesystem, so it sees one route per
|
|
5
|
+
* `app/.../page.*` — perfect for multi-page apps, blind to a single-route SPA that
|
|
6
|
+
* expresses every view as a query param (`/?tab=overview`) or client-side push.
|
|
7
|
+
* Those surfaces only exist in the *rendered* DOM, as the nav's links. This module
|
|
8
|
+
* turns that rendered link set into a surface list: navigate the app's root, read
|
|
9
|
+
* its `<a href>`s, and capture each — no hand-maintained `surfaces` array to drift
|
|
10
|
+
* out of sync with the nav (the same drift the coverage guard exists to catch,
|
|
11
|
+
* removed at the source).
|
|
12
|
+
*
|
|
13
|
+
* The DOM read happens at run time inside a Playwright test (a browser is needed to
|
|
14
|
+
* see hydrated links), so this file holds only the PURE part — turning a list of
|
|
15
|
+
* raw href strings into deduped, keyed, navigable surfaces — which is unit-testable
|
|
16
|
+
* with no browser. {@link defineCrawlCapture} in `runner.ts` does the navigation and
|
|
17
|
+
* feeds the hrefs here.
|
|
18
|
+
*/
|
|
19
|
+
/** A discovered surface: a filename-safe key and the same-origin path to navigate. */
|
|
20
|
+
export type CrawlLink = {
|
|
21
|
+
/** Capture file-name prefix, derived from the URL (see {@link defaultLinkKey}). */
|
|
22
|
+
key: string;
|
|
23
|
+
/** Root-relative path+query to navigate (`/?tab=overview`, `/about`). */
|
|
24
|
+
url: string;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Keep only links whose resolved URL matches: a substring tested against the full
|
|
28
|
+
* href, a RegExp tested against it, or a predicate over the parsed URL. Omit to keep
|
|
29
|
+
* every same-origin link.
|
|
30
|
+
*/
|
|
31
|
+
export type LinkMatch = string | RegExp | ((url: URL) => boolean);
|
|
32
|
+
export type SelectLinksOptions = {
|
|
33
|
+
/** Absolute URL of the crawled page. Relative hrefs resolve against it and only
|
|
34
|
+
* same-origin links are kept (external nav, mailto:, tel:, javascript: dropped). */
|
|
35
|
+
base: string;
|
|
36
|
+
/** Narrow the kept links. Default: every same-origin link. */
|
|
37
|
+
match?: LinkMatch;
|
|
38
|
+
/** Derive the surface key from a link URL. Default: {@link defaultLinkKey}. */
|
|
39
|
+
key?: (url: URL) => string;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Filename-safe, readable key from a link URL. Joins the path segments and the
|
|
43
|
+
* query-param *values* (the discriminator for a tab SPA — `/?tab=overview` →
|
|
44
|
+
* `overview`), so the common single-route-with-`?tab=` case reads cleanly while a
|
|
45
|
+
* multi-segment route (`/blog/post`) still keys as `blog-post`. Param names are
|
|
46
|
+
* dropped (values carry the meaning); pass `key` to {@link selectCrawlLinks} when a
|
|
47
|
+
* project needs a different scheme.
|
|
48
|
+
*/
|
|
49
|
+
export declare function defaultLinkKey(url: URL): string;
|
|
50
|
+
/**
|
|
51
|
+
* Turn a page's raw `<a href>` values into a deduped, keyed surface list.
|
|
52
|
+
*
|
|
53
|
+
* Each href is classified by {@link toLink} (resolve against `base`, keep http(s)
|
|
54
|
+
* same-origin, drop a bare in-page fragment of the crawl root, apply `match`); the
|
|
55
|
+
* survivors are deduped by path+query. Order follows first appearance in `hrefs`, so
|
|
56
|
+
* the capture order is the nav's order — stable across runs.
|
|
57
|
+
*/
|
|
58
|
+
export declare function selectCrawlLinks(hrefs: Iterable<string | null | undefined>, opts: SelectLinksOptions): CrawlLink[];
|
package/dist/crawl.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link-crawl surface discovery, for apps whose surfaces aren't filesystem routes.
|
|
3
|
+
*
|
|
4
|
+
* {@link discoverNextRoutes} reads the filesystem, so it sees one route per
|
|
5
|
+
* `app/.../page.*` — perfect for multi-page apps, blind to a single-route SPA that
|
|
6
|
+
* expresses every view as a query param (`/?tab=overview`) or client-side push.
|
|
7
|
+
* Those surfaces only exist in the *rendered* DOM, as the nav's links. This module
|
|
8
|
+
* turns that rendered link set into a surface list: navigate the app's root, read
|
|
9
|
+
* its `<a href>`s, and capture each — no hand-maintained `surfaces` array to drift
|
|
10
|
+
* out of sync with the nav (the same drift the coverage guard exists to catch,
|
|
11
|
+
* removed at the source).
|
|
12
|
+
*
|
|
13
|
+
* The DOM read happens at run time inside a Playwright test (a browser is needed to
|
|
14
|
+
* see hydrated links), so this file holds only the PURE part — turning a list of
|
|
15
|
+
* raw href strings into deduped, keyed, navigable surfaces — which is unit-testable
|
|
16
|
+
* with no browser. {@link defineCrawlCapture} in `runner.ts` does the navigation and
|
|
17
|
+
* feeds the hrefs here.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Filename-safe, readable key from a link URL. Joins the path segments and the
|
|
21
|
+
* query-param *values* (the discriminator for a tab SPA — `/?tab=overview` →
|
|
22
|
+
* `overview`), so the common single-route-with-`?tab=` case reads cleanly while a
|
|
23
|
+
* multi-segment route (`/blog/post`) still keys as `blog-post`. Param names are
|
|
24
|
+
* dropped (values carry the meaning); pass `key` to {@link selectCrawlLinks} when a
|
|
25
|
+
* project needs a different scheme.
|
|
26
|
+
*/
|
|
27
|
+
export function defaultLinkKey(url) {
|
|
28
|
+
const segs = url.pathname.split('/').filter(Boolean);
|
|
29
|
+
const values = [...url.searchParams].map(([, v]) => v).filter(Boolean);
|
|
30
|
+
const slug = [...segs, ...values]
|
|
31
|
+
.join('-')
|
|
32
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
33
|
+
.replace(/^-+|-+$/g, '')
|
|
34
|
+
.toLowerCase();
|
|
35
|
+
return slug || 'index';
|
|
36
|
+
}
|
|
37
|
+
function matches(url, match) {
|
|
38
|
+
if (match === undefined)
|
|
39
|
+
return true;
|
|
40
|
+
if (typeof match === 'string')
|
|
41
|
+
return url.href.includes(match);
|
|
42
|
+
if (match instanceof RegExp)
|
|
43
|
+
return match.test(url.href);
|
|
44
|
+
return match(url);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Resolve one raw href to a navigable same-origin surface, or `null` to skip it
|
|
48
|
+
* (malformed, mailto:/tel:/javascript:, external, a bare fragment of the crawl root,
|
|
49
|
+
* or filtered out by `match`). Pure per-href classification — the loop in
|
|
50
|
+
* {@link selectCrawlLinks} only has to dedupe what this keeps.
|
|
51
|
+
*/
|
|
52
|
+
function toLink(href, base, keyFor, match) {
|
|
53
|
+
let url;
|
|
54
|
+
try {
|
|
55
|
+
url = new URL(href, base);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null; // malformed href — skip, never throw into a spec
|
|
59
|
+
}
|
|
60
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
61
|
+
return null; // mailto:/tel:/javascript:
|
|
62
|
+
if (url.origin !== base.origin)
|
|
63
|
+
return null; // external link
|
|
64
|
+
const path = url.pathname + url.search;
|
|
65
|
+
// A pure fragment of the crawl root isn't a new surface (it's the same page).
|
|
66
|
+
if (url.hash && path === base.pathname + base.search)
|
|
67
|
+
return null;
|
|
68
|
+
if (!matches(url, match))
|
|
69
|
+
return null;
|
|
70
|
+
url.hash = ''; // navigate the surface, not a scroll anchor within it
|
|
71
|
+
return { key: keyFor(url), url: path };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Turn a page's raw `<a href>` values into a deduped, keyed surface list.
|
|
75
|
+
*
|
|
76
|
+
* Each href is classified by {@link toLink} (resolve against `base`, keep http(s)
|
|
77
|
+
* same-origin, drop a bare in-page fragment of the crawl root, apply `match`); the
|
|
78
|
+
* survivors are deduped by path+query. Order follows first appearance in `hrefs`, so
|
|
79
|
+
* the capture order is the nav's order — stable across runs.
|
|
80
|
+
*/
|
|
81
|
+
export function selectCrawlLinks(hrefs, opts) {
|
|
82
|
+
const base = new URL(opts.base);
|
|
83
|
+
const keyFor = opts.key ?? defaultLinkKey;
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
const out = [];
|
|
86
|
+
for (const href of hrefs) {
|
|
87
|
+
const link = href ? toLink(href, base, keyFor, opts.match) : null;
|
|
88
|
+
if (!link || seen.has(link.url))
|
|
89
|
+
continue;
|
|
90
|
+
seen.add(link.url);
|
|
91
|
+
out.push(link);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
package/dist/diff.d.ts
CHANGED
package/dist/diff.js
CHANGED
|
@@ -35,11 +35,39 @@ export function diffStyleMaps(a, b) {
|
|
|
35
35
|
const ea = a.elements[p];
|
|
36
36
|
const eb = b.elements[p];
|
|
37
37
|
if (!ea || !eb) {
|
|
38
|
-
|
|
38
|
+
const present = (ea ?? eb);
|
|
39
|
+
findings.push({
|
|
40
|
+
kind: 'dom',
|
|
41
|
+
path: p,
|
|
42
|
+
cls: present.cls,
|
|
43
|
+
change: !ea ? 'added' : 'removed',
|
|
44
|
+
...(!ea && eb.component ? { component: eb.component } : {}),
|
|
45
|
+
});
|
|
46
|
+
// An added element has no "before", so the style loop below is skipped —
|
|
47
|
+
// surface its full resting computed style (+ pseudos) as (unset)→value so a
|
|
48
|
+
// new element's styling is reviewable, not just its interaction-state
|
|
49
|
+
// deltas. Removed elements get none (there is no "after" to show).
|
|
50
|
+
if (!ea && eb) {
|
|
51
|
+
const defsB = b.defaults[eb.tag] ?? {};
|
|
52
|
+
for (const pseudo of [null, ...Object.keys(eb.pseudo ?? {})]) {
|
|
53
|
+
const propsB = pseudo ? (eb.pseudo?.[pseudo] ?? {}) : eb.style;
|
|
54
|
+
const pdefsB = pseudo ? (b.defaults[eb.tag + pseudo] ?? defsB) : defsB;
|
|
55
|
+
const props = diffProps({}, propsB, {}, pdefsB, '(unset)', '(unset)');
|
|
56
|
+
if (props.length)
|
|
57
|
+
findings.push({ kind: 'style', path: p, cls: eb.cls, pseudo, props });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
39
60
|
continue;
|
|
40
61
|
}
|
|
41
62
|
if (ea.tag !== eb.tag) {
|
|
42
|
-
findings.push({
|
|
63
|
+
findings.push({
|
|
64
|
+
kind: 'dom',
|
|
65
|
+
path: p,
|
|
66
|
+
cls: ea.cls,
|
|
67
|
+
change: 'retagged',
|
|
68
|
+
detail: `<${ea.tag}> → <${eb.tag}>`,
|
|
69
|
+
...(eb.component ? { component: eb.component } : {}),
|
|
70
|
+
});
|
|
43
71
|
continue;
|
|
44
72
|
}
|
|
45
73
|
const defsA = a.defaults[ea.tag] ?? {};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
|
|
2
2
|
export type { StyleMap, CaptureOptions, ElementEntry, Rect } from './capture.js';
|
|
3
|
-
export { defineStyleMapCapture } from './runner.js';
|
|
4
|
-
export type { Surface, DefineOptions } from './runner.js';
|
|
3
|
+
export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
|
|
4
|
+
export type { Surface, DefineOptions, CrawlOptions } from './runner.js';
|
|
5
5
|
export { coverageGaps } from './coverage.js';
|
|
6
6
|
export type { CoverageGaps } from './coverage.js';
|
|
7
7
|
export { discoverNextRoutes } from './routes.js';
|
|
8
8
|
export type { DiscoveredRoute } from './routes.js';
|
|
9
|
+
export { selectCrawlLinks, defaultLinkKey } from './crawl.js';
|
|
10
|
+
export type { CrawlLink, LinkMatch, SelectLinksOptions } from './crawl.js';
|
|
9
11
|
export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
|
|
10
12
|
export type { Finding, PropChange, SurfaceDiff, DiffCounts, ContentChange } from './diff.js';
|
|
11
13
|
export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
|
|
2
|
-
export { defineStyleMapCapture } from './runner.js';
|
|
2
|
+
export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
|
|
3
3
|
export { coverageGaps } from './coverage.js';
|
|
4
4
|
export { discoverNextRoutes } from './routes.js';
|
|
5
|
+
export { selectCrawlLinks, defaultLinkKey } from './crawl.js';
|
|
5
6
|
export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
|
|
6
7
|
export { generateStyleMapReport, summarizeProps, prettyLabel } from './report.js';
|
package/dist/report.js
CHANGED
|
@@ -432,12 +432,51 @@ function beforeAfterTable(rows) {
|
|
|
432
432
|
...rows.map((r) => `| \`${r.prop}\` | ${cell(r.before)} | ${cell(r.after)} |`),
|
|
433
433
|
];
|
|
434
434
|
}
|
|
435
|
+
// A brand-new element has no meaningful "before", so its resting style renders
|
|
436
|
+
// value-only (the After column), mirroring the added-element interaction-states table.
|
|
437
|
+
function valueTable(rows) {
|
|
438
|
+
return ['| Property | Value |', '| --- | --- |', ...rows.map((r) => `| \`${r.prop}\` | ${cell(r.after)} |`)];
|
|
439
|
+
}
|
|
440
|
+
/** `Button (variant=primary, size=sm)` — the React component + sanitized props
|
|
441
|
+
* the element captured (advisory; present only with captureComponent). */
|
|
442
|
+
function renderComponent(c) {
|
|
443
|
+
const entries = Object.entries(c.props ?? {});
|
|
444
|
+
const props = entries.length ? ` (${entries.map(([k, v]) => `${k}=${v}`).join(', ')})` : '';
|
|
445
|
+
return `\`${c.name}\`${props}`;
|
|
446
|
+
}
|
|
435
447
|
/** One element's heading + body lines (no leading blank, no ×N suffix). */
|
|
448
|
+
// Base/pseudo style rows. Added elements render value-only (no meaningful before).
|
|
449
|
+
function styleSection(styles, added) {
|
|
450
|
+
const out = [];
|
|
451
|
+
for (const s of styles) {
|
|
452
|
+
const rows = summarizeProps(s.props);
|
|
453
|
+
if (rows.length)
|
|
454
|
+
out.push('', s.pseudo ? `On \`${s.pseudo}\`:` : 'Style:', '', ...(added ? valueTable(rows) : beforeAfterTable(rows)));
|
|
455
|
+
}
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
// Forced :hover/:focus/:active rows. Added: value-only; changed: before → after.
|
|
459
|
+
function statesSection(states, added) {
|
|
460
|
+
const rows = [];
|
|
461
|
+
for (const st of states)
|
|
462
|
+
for (const c of summarizeProps(st.props))
|
|
463
|
+
rows.push(added
|
|
464
|
+
? `| \`:${st.state}\` | \`${c.prop}\` | ${cell(c.after)} |`
|
|
465
|
+
: `| \`:${st.state}\` | \`${c.prop}\` | ${cell(c.before)} → ${cell(c.after)} |`);
|
|
466
|
+
if (!rows.length)
|
|
467
|
+
return [];
|
|
468
|
+
return [
|
|
469
|
+
'',
|
|
470
|
+
added ? 'Interactive states:' : 'Interactive-state changes:',
|
|
471
|
+
'',
|
|
472
|
+
added ? '| State | Property | Value |' : '| State | Property | Before → After |',
|
|
473
|
+
'| --- | --- | --- |',
|
|
474
|
+
...rows,
|
|
475
|
+
];
|
|
476
|
+
}
|
|
436
477
|
function renderOneElement(group) {
|
|
437
478
|
const label = prettyLabel(group[0].path, group[0].cls);
|
|
438
479
|
const dom = group.find((f) => f.kind === 'dom');
|
|
439
|
-
const styles = group.filter((f) => f.kind === 'style');
|
|
440
|
-
const states = group.filter((f) => f.kind === 'state');
|
|
441
480
|
if (dom?.change === 'removed')
|
|
442
481
|
return { head: `**Removed** \`${label}\``, body: [] };
|
|
443
482
|
const added = dom?.change === 'added';
|
|
@@ -447,21 +486,12 @@ function renderOneElement(group) {
|
|
|
447
486
|
? `**Retagged** \`${label}\` ${dom.detail ?? ''}`
|
|
448
487
|
: `**\`${label}\`**`;
|
|
449
488
|
const body = [];
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const rows = [];
|
|
457
|
-
for (const st of states)
|
|
458
|
-
for (const c of summarizeProps(st.props))
|
|
459
|
-
rows.push(added
|
|
460
|
-
? `| \`:${st.state}\` | \`${c.prop}\` | ${cell(c.after)} |`
|
|
461
|
-
: `| \`:${st.state}\` | \`${c.prop}\` | ${cell(c.before)} → ${cell(c.after)} |`);
|
|
462
|
-
if (rows.length)
|
|
463
|
-
body.push('', added ? 'Interactive states:' : 'Interactive-state changes:', '', added ? '| State | Property | Value |' : '| State | Property | Before → After |', '| --- | --- | --- |', ...rows);
|
|
464
|
-
}
|
|
489
|
+
// React component that rendered the element (added/retagged carry it on the dom
|
|
490
|
+
// finding) — surfaced first so a reviewer sees `Button (variant=primary)`.
|
|
491
|
+
if (dom?.component)
|
|
492
|
+
body.push('', `React component: ${renderComponent(dom.component)}`);
|
|
493
|
+
body.push(...styleSection(group.filter((f) => f.kind === 'style'), added));
|
|
494
|
+
body.push(...statesSection(group.filter((f) => f.kind === 'state'), added));
|
|
465
495
|
// Existing element with nothing left to show (all derived) → skip; an
|
|
466
496
|
// added/removed/retagged element always renders its heading.
|
|
467
497
|
if (!dom && !body.length)
|
|
@@ -584,6 +614,10 @@ function cleanFindings(findings) {
|
|
|
584
614
|
for (const group of groupByPath(findings)) {
|
|
585
615
|
const base = group.find((f) => f.kind === 'style' && f.pseudo === null);
|
|
586
616
|
const baseChanged = new Set(base?.props.map((p) => p.prop) ?? []);
|
|
617
|
+
// For an ADDED element the base style is a full (unset)→value snapshot, not a
|
|
618
|
+
// delta — so a forced-state value is never an "echo" of a base *change*; keep
|
|
619
|
+
// every state row (suppressing them would drop a real :hover/:focus value).
|
|
620
|
+
const isAdded = group.some((f) => f.kind === 'dom' && f.change === 'added');
|
|
587
621
|
for (const f of group) {
|
|
588
622
|
if (f.kind === 'dom') {
|
|
589
623
|
out.push(f);
|
|
@@ -591,7 +625,9 @@ function cleanFindings(findings) {
|
|
|
591
625
|
}
|
|
592
626
|
const props = f.kind === 'style'
|
|
593
627
|
? f.props.filter((p) => !DERIVED_PROPS.has(p.prop))
|
|
594
|
-
: f.props.filter((p) => !STATE_STRIP.has(p.prop) &&
|
|
628
|
+
: f.props.filter((p) => !STATE_STRIP.has(p.prop) &&
|
|
629
|
+
(isAdded || !baseChanged.has(p.prop)) &&
|
|
630
|
+
!(isNonValue(p.before) && isNonValue(p.after)));
|
|
595
631
|
if (props.length)
|
|
596
632
|
out.push({ ...f, props });
|
|
597
633
|
}
|
package/dist/runner.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type LinkMatch } from './crawl.js';
|
|
1
2
|
import type { Page } from '@playwright/test';
|
|
2
3
|
/**
|
|
3
4
|
* A surface is one deterministic page state worth certifying: a route plus
|
|
@@ -98,6 +99,12 @@ export type DefineOptions = {
|
|
|
98
99
|
* `CaptureOptions.captureText` and the README's "Optional: content layer".
|
|
99
100
|
*/
|
|
100
101
|
captureText?: boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Opt-in React layer (default OFF). Record the component + sanitized props that
|
|
104
|
+
* rendered each element so the report can name `Button (variant=primary)`.
|
|
105
|
+
* Advisory only — never gates. See `CaptureOptions.captureComponent`.
|
|
106
|
+
*/
|
|
107
|
+
captureComponent?: boolean;
|
|
101
108
|
};
|
|
102
109
|
/**
|
|
103
110
|
* Let SSE (EventSource) requests bypass HAR record/replay and reach the live
|
|
@@ -122,6 +129,8 @@ export declare function passLiveStreams(page: Page, url: string): Promise<void>;
|
|
|
122
129
|
* forces it on either way.
|
|
123
130
|
*/
|
|
124
131
|
export declare function defaultSelfCheck(replayFrom: string | undefined, env?: string | undefined): boolean;
|
|
132
|
+
/** The capture settings every capturer shares (everything bar the surface set). */
|
|
133
|
+
type CaptureConfig = Omit<DefineOptions, 'surfaces' | 'expected' | 'exclude'>;
|
|
125
134
|
/**
|
|
126
135
|
* Generate one Playwright test per surface × width that captures the style
|
|
127
136
|
* map to `<baseDir>/<dir>/<key>@<width>.json.gz`. Captures are made
|
|
@@ -135,4 +144,46 @@ export declare function defaultSelfCheck(replayFrom: string | undefined, env?: s
|
|
|
135
144
|
* defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
|
|
136
145
|
* ```
|
|
137
146
|
*/
|
|
138
|
-
export declare function defineStyleMapCapture(
|
|
147
|
+
export declare function defineStyleMapCapture(options: DefineOptions): void;
|
|
148
|
+
/** Options for {@link defineCrawlCapture}: where to crawl, how to filter/key the
|
|
149
|
+
* links, and the viewport sweep — plus the shared capture settings. */
|
|
150
|
+
export type CrawlOptions = CaptureConfig & {
|
|
151
|
+
/** URL to crawl for surface links (e.g. `/`). Its same-origin `<a href>`s become
|
|
152
|
+
* the surface set. */
|
|
153
|
+
from: string;
|
|
154
|
+
/** Narrow the discovered links — substring, RegExp, or predicate over the URL
|
|
155
|
+
* (e.g. `/\?tab=/` to capture only the tab views). Default: every same-origin link. */
|
|
156
|
+
match?: LinkMatch;
|
|
157
|
+
/** Derive a surface key from a link URL. Default: path+query slug (`/?tab=x` → `x`). */
|
|
158
|
+
key?: (url: URL) => string;
|
|
159
|
+
/** Viewport widths swept for every discovered surface — one per @media band. */
|
|
160
|
+
widths: number[];
|
|
161
|
+
/** Viewport height per width (default 800). */
|
|
162
|
+
height?: number | ((width: number) => number);
|
|
163
|
+
/** Selectors skipped on every surface (live regions, third-party embeds). */
|
|
164
|
+
ignore?: string[];
|
|
165
|
+
/** Max ms to wait for the crawl root's links to render before reading them
|
|
166
|
+
* (an SPA hydrates its nav client-side). Default 15000. */
|
|
167
|
+
linkTimeout?: number;
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* Like {@link defineStyleMapCapture}, but the surface set is DISCOVERED at run time
|
|
171
|
+
* by crawling a page's links instead of being hand-listed — for a single-route SPA
|
|
172
|
+
* whose views are `?tab=`/client-routed and so invisible to the filesystem
|
|
173
|
+
* {@link discoverNextRoutes}. It navigates `from`, reads its same-origin `<a href>`s
|
|
174
|
+
* (filtered by `match`), and captures each as a surface keyed by `key`. The app just
|
|
175
|
+
* has to render its nav as real links; nothing to hand-maintain, so the surface list
|
|
176
|
+
* can't drift from the nav.
|
|
177
|
+
*
|
|
178
|
+
* One Playwright test does the whole sweep (the link set isn't known until a browser
|
|
179
|
+
* has rendered the page, so per-surface tests can't be generated at collection time).
|
|
180
|
+
* Per-surface failures are aggregated — one bad surface reports without hiding the
|
|
181
|
+
* rest. Replay/self-check/clock-freeze behave exactly as for explicit surfaces.
|
|
182
|
+
*
|
|
183
|
+
* ```ts
|
|
184
|
+
* // styleproof.spec.ts — capture every tab the nav links to
|
|
185
|
+
* defineCrawlCapture({ from: '/', match: /\?tab=/, widths: [1440, 1024, 768], dir: process.env.STYLEMAP_DIR });
|
|
186
|
+
* ```
|
|
187
|
+
*/
|
|
188
|
+
export declare function defineCrawlCapture(options: CrawlOptions): void;
|
|
189
|
+
export {};
|
package/dist/runner.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
|
|
5
5
|
import { diffStyleMaps } from './diff.js';
|
|
6
6
|
import { coverageGaps } from './coverage.js';
|
|
7
|
+
import { selectCrawlLinks } from './crawl.js';
|
|
7
8
|
/** One-line description of the first drift finding, for the self-check error. */
|
|
8
9
|
function driftDesc(f) {
|
|
9
10
|
if (f.kind === 'dom')
|
|
@@ -81,9 +82,10 @@ async function assertDeterministic(page, surface, first, captureText, pending) {
|
|
|
81
82
|
`First: ${driftDesc(drift[0])}`);
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
|
-
/** Drive one surface at one width to a settled state and save its style map (+ screenshot).
|
|
85
|
+
/** Drive one surface at one width to a settled state and save its style map (+ screenshot).
|
|
86
|
+
* The caller owns the test timeout (one-per-test for explicit surfaces, one budget for
|
|
87
|
+
* the whole crawl) so a multi-surface crawl can't reset its own deadline mid-loop. */
|
|
85
88
|
async function captureSurface(page, surface, width, s) {
|
|
86
|
-
test.setTimeout(180_000);
|
|
87
89
|
await pinInputs(page, `${surface.key}@${width}.har`, s);
|
|
88
90
|
const height = typeof surface.height === 'function' ? surface.height(width) : (surface.height ?? 800);
|
|
89
91
|
await page.setViewportSize({ width, height });
|
|
@@ -96,6 +98,7 @@ async function captureSurface(page, surface, width, s) {
|
|
|
96
98
|
const map = await captureStyleMap(page, {
|
|
97
99
|
ignore: surface.ignore ?? [],
|
|
98
100
|
captureText: s.captureText,
|
|
101
|
+
captureComponent: s.captureComponent,
|
|
99
102
|
pendingRequests: requests.pending,
|
|
100
103
|
});
|
|
101
104
|
if (s.selfCheck)
|
|
@@ -122,6 +125,27 @@ async function captureSurface(page, surface, width, s) {
|
|
|
122
125
|
export function defaultSelfCheck(replayFrom, env = process.env.STYLEPROOF_SELFCHECK) {
|
|
123
126
|
return env === '1' || !replayFrom;
|
|
124
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Apply the capture defaults once, so explicit-surface and crawl capture can't
|
|
130
|
+
* drift — the replay boundary, frozen clock and self-check policy resolve to the
|
|
131
|
+
* same thing whichever entry point you use. Env fallbacks (`STYLEPROOF_REPLAY_*`)
|
|
132
|
+
* live here too, so a single spec line keeps the documented behaviour.
|
|
133
|
+
*/
|
|
134
|
+
function resolveSettings(c) {
|
|
135
|
+
const replayFrom = c.replayFrom ?? process.env.STYLEPROOF_REPLAY_FROM;
|
|
136
|
+
return {
|
|
137
|
+
dir: c.dir,
|
|
138
|
+
baseDir: c.baseDir ?? '__stylemaps__',
|
|
139
|
+
screenshots: c.screenshots ?? true,
|
|
140
|
+
replayFrom,
|
|
141
|
+
replayUrl: c.replayUrl ?? process.env.STYLEPROOF_REPLAY_URL ?? '**/api/**',
|
|
142
|
+
freezeClock: c.freezeClock ?? true,
|
|
143
|
+
clockTime: c.clockTime ?? '2025-01-01T00:00:00Z',
|
|
144
|
+
selfCheck: c.selfCheck ?? defaultSelfCheck(replayFrom),
|
|
145
|
+
captureText: c.captureText ?? false,
|
|
146
|
+
captureComponent: c.captureComponent ?? false,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
125
149
|
/**
|
|
126
150
|
* Generate one Playwright test per surface × width that captures the style
|
|
127
151
|
* map to `<baseDir>/<dir>/<key>@<width>.json.gz`. Captures are made
|
|
@@ -135,18 +159,9 @@ export function defaultSelfCheck(replayFrom, env = process.env.STYLEPROOF_SELFCH
|
|
|
135
159
|
* defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
|
|
136
160
|
* ```
|
|
137
161
|
*/
|
|
138
|
-
export function defineStyleMapCapture(
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
baseDir,
|
|
142
|
-
screenshots,
|
|
143
|
-
replayFrom,
|
|
144
|
-
replayUrl,
|
|
145
|
-
freezeClock,
|
|
146
|
-
clockTime,
|
|
147
|
-
selfCheck,
|
|
148
|
-
captureText,
|
|
149
|
-
};
|
|
162
|
+
export function defineStyleMapCapture(options) {
|
|
163
|
+
const { surfaces, expected, exclude = {}, dir } = options;
|
|
164
|
+
const settings = resolveSettings(options);
|
|
150
165
|
// Coverage guard. Runs in the NORMAL test suite (NOT gated on a capture dir), so
|
|
151
166
|
// a route added without a surface fails the app's own tests — long before, and
|
|
152
167
|
// independent of, a capture run. This is the one gap captures can't catch: a
|
|
@@ -168,8 +183,77 @@ export function defineStyleMapCapture({ surfaces, expected, exclude = {}, dir, b
|
|
|
168
183
|
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
169
184
|
for (const surface of surfaces) {
|
|
170
185
|
for (const width of surface.widths) {
|
|
171
|
-
test(`${surface.key} @ ${width}`, ({ page }) =>
|
|
186
|
+
test(`${surface.key} @ ${width}`, ({ page }) => {
|
|
187
|
+
test.setTimeout(180_000);
|
|
188
|
+
return captureSurface(page, surface, width, settings);
|
|
189
|
+
});
|
|
172
190
|
}
|
|
173
191
|
}
|
|
174
192
|
});
|
|
175
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* Like {@link defineStyleMapCapture}, but the surface set is DISCOVERED at run time
|
|
196
|
+
* by crawling a page's links instead of being hand-listed — for a single-route SPA
|
|
197
|
+
* whose views are `?tab=`/client-routed and so invisible to the filesystem
|
|
198
|
+
* {@link discoverNextRoutes}. It navigates `from`, reads its same-origin `<a href>`s
|
|
199
|
+
* (filtered by `match`), and captures each as a surface keyed by `key`. The app just
|
|
200
|
+
* has to render its nav as real links; nothing to hand-maintain, so the surface list
|
|
201
|
+
* can't drift from the nav.
|
|
202
|
+
*
|
|
203
|
+
* One Playwright test does the whole sweep (the link set isn't known until a browser
|
|
204
|
+
* has rendered the page, so per-surface tests can't be generated at collection time).
|
|
205
|
+
* Per-surface failures are aggregated — one bad surface reports without hiding the
|
|
206
|
+
* rest. Replay/self-check/clock-freeze behave exactly as for explicit surfaces.
|
|
207
|
+
*
|
|
208
|
+
* ```ts
|
|
209
|
+
* // styleproof.spec.ts — capture every tab the nav links to
|
|
210
|
+
* defineCrawlCapture({ from: '/', match: /\?tab=/, widths: [1440, 1024, 768], dir: process.env.STYLEMAP_DIR });
|
|
211
|
+
* ```
|
|
212
|
+
*/
|
|
213
|
+
export function defineCrawlCapture(options) {
|
|
214
|
+
const { from, match, key, widths, height, ignore, linkTimeout = 15_000, dir } = options;
|
|
215
|
+
const settings = resolveSettings(options);
|
|
216
|
+
test.describe('styleproof crawl-capture', () => {
|
|
217
|
+
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
218
|
+
test('discover surfaces by crawling links, then capture each', async ({ page }) => {
|
|
219
|
+
// 1. Load the root and wait for its nav links to hydrate — an SPA renders them
|
|
220
|
+
// client-side, so they aren't in the initial HTML.
|
|
221
|
+
await page.goto(from, { waitUntil: 'load' });
|
|
222
|
+
await page.waitForSelector('a[href]', { timeout: linkTimeout });
|
|
223
|
+
const hrefs = await page.$$eval('a[href]', (els) => els.map((e) => e.getAttribute('href')));
|
|
224
|
+
const links = selectCrawlLinks(hrefs, { base: page.url(), match, key });
|
|
225
|
+
if (links.length === 0) {
|
|
226
|
+
throw new Error(`styleproof crawl: no links matched at ${from}. The nav must render same-origin ` +
|
|
227
|
+
`<a href> links (a button-only nav exposes nothing to crawl), and \`match\` must keep them.`);
|
|
228
|
+
}
|
|
229
|
+
// Budget the whole sweep up front: one test captures every surface, and
|
|
230
|
+
// captureSurface no longer sets its own timeout, so size it to the work found.
|
|
231
|
+
test.setTimeout(Math.max(180_000, links.length * widths.length * 60_000));
|
|
232
|
+
// 2. Capture each discovered surface. Aggregate failures so one bad surface
|
|
233
|
+
// reports without skipping the rest — they're an independent set, not a chain.
|
|
234
|
+
const failures = [];
|
|
235
|
+
for (const link of links) {
|
|
236
|
+
for (const width of widths) {
|
|
237
|
+
const surface = {
|
|
238
|
+
key: link.key,
|
|
239
|
+
go: async (p) => {
|
|
240
|
+
await p.goto(link.url, { waitUntil: 'load' });
|
|
241
|
+
},
|
|
242
|
+
widths: [width],
|
|
243
|
+
ignore,
|
|
244
|
+
height,
|
|
245
|
+
};
|
|
246
|
+
try {
|
|
247
|
+
await captureSurface(page, surface, width, settings);
|
|
248
|
+
}
|
|
249
|
+
catch (e) {
|
|
250
|
+
failures.push(`${link.key} @ ${width}: ${e.message}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (failures.length) {
|
|
255
|
+
throw new Error(`styleproof crawl-capture: ${failures.length} surface(s) failed:\n${failures.join('\n')}`);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.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",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"devDependencies": {
|
|
76
76
|
"@eslint/js": "^10.0.1",
|
|
77
77
|
"@playwright/test": "^1.60.0",
|
|
78
|
-
"@types/node": "^
|
|
78
|
+
"@types/node": "^26.0.0",
|
|
79
79
|
"@types/pngjs": "^6.0.5",
|
|
80
80
|
"eslint": "^10.5.0",
|
|
81
81
|
"globals": "^17.6.0",
|