styleproof 2.2.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 +18 -0
- package/README.md +15 -0
- package/dist/crawl.d.ts +58 -0
- package/dist/crawl.js +94 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/runner.d.ts +46 -1
- package/dist/runner.js +98 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,24 @@ 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
|
+
|
|
10
28
|
## [2.2.0] - 2026-06-23
|
|
11
29
|
|
|
12
30
|
### 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.
|
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/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/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
|
|
@@ -128,6 +129,8 @@ export declare function passLiveStreams(page: Page, url: string): Promise<void>;
|
|
|
128
129
|
* forces it on either way.
|
|
129
130
|
*/
|
|
130
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'>;
|
|
131
134
|
/**
|
|
132
135
|
* Generate one Playwright test per surface × width that captures the style
|
|
133
136
|
* map to `<baseDir>/<dir>/<key>@<width>.json.gz`. Captures are made
|
|
@@ -141,4 +144,46 @@ export declare function defaultSelfCheck(replayFrom: string | undefined, env?: s
|
|
|
141
144
|
* defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
|
|
142
145
|
* ```
|
|
143
146
|
*/
|
|
144
|
-
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 });
|
|
@@ -123,6 +125,27 @@ async function captureSurface(page, surface, width, s) {
|
|
|
123
125
|
export function defaultSelfCheck(replayFrom, env = process.env.STYLEPROOF_SELFCHECK) {
|
|
124
126
|
return env === '1' || !replayFrom;
|
|
125
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
|
+
}
|
|
126
149
|
/**
|
|
127
150
|
* Generate one Playwright test per surface × width that captures the style
|
|
128
151
|
* map to `<baseDir>/<dir>/<key>@<width>.json.gz`. Captures are made
|
|
@@ -136,19 +159,9 @@ export function defaultSelfCheck(replayFrom, env = process.env.STYLEPROOF_SELFCH
|
|
|
136
159
|
* defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
|
|
137
160
|
* ```
|
|
138
161
|
*/
|
|
139
|
-
export function defineStyleMapCapture(
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
baseDir,
|
|
143
|
-
screenshots,
|
|
144
|
-
replayFrom,
|
|
145
|
-
replayUrl,
|
|
146
|
-
freezeClock,
|
|
147
|
-
clockTime,
|
|
148
|
-
selfCheck,
|
|
149
|
-
captureText,
|
|
150
|
-
captureComponent,
|
|
151
|
-
};
|
|
162
|
+
export function defineStyleMapCapture(options) {
|
|
163
|
+
const { surfaces, expected, exclude = {}, dir } = options;
|
|
164
|
+
const settings = resolveSettings(options);
|
|
152
165
|
// Coverage guard. Runs in the NORMAL test suite (NOT gated on a capture dir), so
|
|
153
166
|
// a route added without a surface fails the app's own tests — long before, and
|
|
154
167
|
// independent of, a capture run. This is the one gap captures can't catch: a
|
|
@@ -170,8 +183,77 @@ export function defineStyleMapCapture({ surfaces, expected, exclude = {}, dir, b
|
|
|
170
183
|
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
171
184
|
for (const surface of surfaces) {
|
|
172
185
|
for (const width of surface.widths) {
|
|
173
|
-
test(`${surface.key} @ ${width}`, ({ page }) =>
|
|
186
|
+
test(`${surface.key} @ ${width}`, ({ page }) => {
|
|
187
|
+
test.setTimeout(180_000);
|
|
188
|
+
return captureSurface(page, surface, width, settings);
|
|
189
|
+
});
|
|
174
190
|
}
|
|
175
191
|
}
|
|
176
192
|
});
|
|
177
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",
|