styleproof 2.2.0 → 2.3.1

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 CHANGED
@@ -7,6 +7,38 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.3.1] - 2026-06-23
11
+
12
+ ### Fixed
13
+
14
+ - **Animation freeze is now deterministic for content that mounts during the
15
+ settle.** Motion longhands (declared `animation`/`transition`) were read _before_
16
+ the settle, so an element that mounts while the page settles — a status glyph
17
+ gated on a snapshot fetch — missed that read: its declared `animation-duration`
18
+ was folded back on a run where it mounted early but left frozen to `0s` on a run
19
+ where it mounted late, surfacing as a self-check `animation-duration: 0s ↔ 1.6s`
20
+ non-deterministic failure. Motion is now read on the settled DOM (the freeze is
21
+ lifted only for that read), so a late-mounted animated element is captured
22
+ identically every run.
23
+
24
+ ## [2.3.0] - 2026-06-23
25
+
26
+ ### Added
27
+
28
+ - **`defineCrawlCapture` — discover surfaces by crawling rendered links.**
29
+ `discoverNextRoutes` reads the filesystem, so it only sees one surface per
30
+ `app/**` page — blind to a single-route SPA whose views are query params
31
+ (`/?tab=overview`) or client-routed, which exist only in the rendered nav as its
32
+ links. `defineCrawlCapture({ from, match, widths, dir })` loads a root URL, reads
33
+ its same-origin `<a href>`s (filtered by `match`), and captures each as a surface
34
+ keyed from its URL (`/?tab=overview` → `overview`; override with `key`). The
35
+ surface set _is_ the nav, so there's no hand-maintained `surfaces` list to drift
36
+ out of sync. The app just has to render its nav as real links — a button-only nav
37
+ exposes nothing to crawl. Replay, self-check and clock-freeze behave exactly as
38
+ for explicit surfaces. Also exported: `selectCrawlLinks` / `defaultLinkKey` (the
39
+ pure link-selection helpers) and the `CrawlOptions` / `CrawlLink` / `LinkMatch`
40
+ types.
41
+
10
42
  ## [2.2.0] - 2026-06-23
11
43
 
12
44
  ### 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/capture.js CHANGED
@@ -562,15 +562,26 @@ export async function captureStyleMap(page, options = {}) {
562
562
  // can't share a module-scope function) call ONE definition — no duplicated source.
563
563
  // It persists on `window` for every evaluate below (no navigation between them).
564
564
  await page.evaluate(injectPathOf);
565
- // Motion longhands first (FREEZE_CSS would null them), then everything else.
566
- // Motion never carries text it's a settled end state of declared motion.
567
- const motion = await page.evaluate(capturePage, { ignore, motionOnly: true, captureText: false });
568
- await page.addStyleTag({ content: FREEZE_CSS });
565
+ // Freeze motion BEFORE settling: animating elements would otherwise read as
566
+ // perpetual churn during the settle, and any content that mounts during the
567
+ // settle must be frozen by the time we read it below.
568
+ const freezeTag = await page.addStyleTag({ content: FREEZE_CSS });
569
569
  // Settle: wait for async content to finish painting so base and head capture
570
570
  // the same loaded state, and collect any region still changing on its own
571
571
  // (a live stream/ticker) to exclude — animations are frozen above, so only
572
572
  // real content/layout churn lands here.
573
573
  const volatile = await detectVolatile(page, ignore, stabilize, captureText, options.pendingRequests);
574
+ // Motion longhands (transition/animation) are read separately so declared
575
+ // motion is verified even though every other value is a frozen end state.
576
+ // Read them on the SETTLED DOM, not before it: capturing pre-settle missed any
577
+ // element that mounts DURING the settle (e.g. a late status glyph), so its
578
+ // declared `animation-duration` was folded back on the run where it happened to
579
+ // mount early but left frozen (0s) on the run where it mounted late — a
580
+ // self-check "non-deterministic" flip. Lift the freeze just for this read (it
581
+ // nulls motion to 0s), then re-apply it before reading everything else.
582
+ await freezeTag.evaluate((el) => el.remove());
583
+ const motion = await page.evaluate(capturePage, { ignore, motionOnly: true, captureText: false });
584
+ await page.addStyleTag({ content: FREEZE_CSS });
574
585
  const base = await page.evaluate(capturePage, { ignore, motionOnly: false, captureText, captureComponent });
575
586
  dropVolatile(base.elements, volatile);
576
587
  warnUntraversed(base.shadowHosts, base.sameOriginFrames);
@@ -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({ surfaces, expected, exclude, dir, baseDir, screenshots, replayFrom, replayUrl, freezeClock, clockTime, selfCheck, captureText, captureComponent, }: DefineOptions): void;
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({ surfaces, expected, exclude = {}, dir, baseDir = '__stylemaps__', screenshots = true, replayFrom = process.env.STYLEPROOF_REPLAY_FROM, replayUrl = process.env.STYLEPROOF_REPLAY_URL ?? '**/api/**', freezeClock = true, clockTime = '2025-01-01T00:00:00Z', selfCheck = defaultSelfCheck(replayFrom), captureText = false, captureComponent = false, }) {
140
- const settings = {
141
- dir: dir,
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 }) => captureSurface(page, surface, width, settings));
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.2.0",
3
+ "version": "2.3.1",
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",