styleproof 2.0.0 → 2.2.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/dist/runner.d.ts CHANGED
@@ -7,7 +7,11 @@ import type { Page } from '@playwright/test';
7
7
  export type Surface = {
8
8
  /** Capture file name prefix; must be unique. */
9
9
  key: string;
10
- /** Navigate and drive the page to the state, ending settled (fonts loaded, entrance animations done). */
10
+ /**
11
+ * Navigate and drive the page to the state. Only reach the state — StyleProof
12
+ * settles it for you (waits out in-flight data and fonts, freezes animations)
13
+ * before reading, so you don't hand-roll `networkidle`/`fonts.ready` waits here.
14
+ */
11
15
  go: (page: Page) => Promise<void>;
12
16
  /** Selectors for nondeterministic regions (live data, third-party embeds); skipped entirely. */
13
17
  ignore?: string[];
@@ -18,6 +22,23 @@ export type Surface = {
18
22
  };
19
23
  export type DefineOptions = {
20
24
  surfaces: Surface[];
25
+ /**
26
+ * The full set of surface keys the app knows it has — its route/view universe,
27
+ * typically derived from a registry (e.g. an app's list of routes or view ids).
28
+ * When set, StyleProof emits a coverage-guard test (in the NORMAL suite, not
29
+ * gated on a capture dir) that fails if any expected key is neither captured (a
30
+ * surface) nor in `exclude`. This is what stops a newly added route from
31
+ * shipping uncaptured: the gate can only diff what a spec lists, so without this
32
+ * a forgotten surface is silently invisible. Omit to opt out (no guard).
33
+ */
34
+ expected?: string[];
35
+ /**
36
+ * Expected keys deliberately NOT captured, each mapped to the reason — a visible,
37
+ * reviewed opt-out ledger. Keeps the coverage guard green for known gaps without
38
+ * letting them hide: an entry whose key isn't in `expected` (a renamed/removed
39
+ * route) also fails the guard, so the ledger can't rot.
40
+ */
41
+ exclude?: Record<string, string>;
21
42
  /**
22
43
  * Output directory label. Convention: drive it from an env var so the same
23
44
  * spec captures `before`, `after`, or a CI label — and skips entirely when
@@ -58,10 +79,16 @@ export type DefineOptions = {
58
79
  /** Fixed instant for the frozen clock (default `2025-01-01T00:00:00Z`). */
59
80
  clockTime?: string | number | Date;
60
81
  /**
61
- * Capture each surface twice and fail if the computed styles differ — proves
62
- * the capture is deterministic (catches a replay gap falling through to the
63
- * live backend, or unseeded client randomness) instead of letting it surface
64
- * as a phantom change on an unrelated diff. Default from STYLEPROOF_SELFCHECK=1.
82
+ * Capture each surface twice and fail if the computed styles differ — proves the
83
+ * capture is deterministic (catches a replay gap falling through to the live
84
+ * backend, or unseeded client randomness) instead of letting it surface as a
85
+ * phantom change on an unrelated diff.
86
+ *
87
+ * Defaults ON for the RECORDING run and OFF for the REPLAY run: live nondeterminism
88
+ * surfaces while recording against the real backend, whereas the replay run renders
89
+ * against the recorded HAR and is deterministic by construction — so self-checking it
90
+ * just doubles the work. `STYLEPROOF_SELFCHECK=1` forces it on for both; pass
91
+ * `selfCheck` explicitly to override.
65
92
  */
66
93
  selfCheck?: boolean;
67
94
  /**
@@ -71,6 +98,12 @@ export type DefineOptions = {
71
98
  * `CaptureOptions.captureText` and the README's "Optional: content layer".
72
99
  */
73
100
  captureText?: boolean;
101
+ /**
102
+ * Opt-in React layer (default OFF). Record the component + sanitized props that
103
+ * rendered each element so the report can name `Button (variant=primary)`.
104
+ * Advisory only — never gates. See `CaptureOptions.captureComponent`.
105
+ */
106
+ captureComponent?: boolean;
74
107
  };
75
108
  /**
76
109
  * Let SSE (EventSource) requests bypass HAR record/replay and reach the live
@@ -87,6 +120,14 @@ export type DefineOptions = {
87
120
  * recently added route first); non-stream requests `fallback()` to the HAR.
88
121
  */
89
122
  export declare function passLiveStreams(page: Page, url: string): Promise<void>;
123
+ /**
124
+ * Default for `selfCheck` when the consumer didn't set it: ON when RECORDING (no
125
+ * `replayFrom`) — that's where live nondeterminism surfaces — and OFF when REPLAYING,
126
+ * since the replay run renders against the recorded HAR and is deterministic by
127
+ * construction, so self-checking it just doubles the work. `STYLEPROOF_SELFCHECK=1`
128
+ * forces it on either way.
129
+ */
130
+ export declare function defaultSelfCheck(replayFrom: string | undefined, env?: string | undefined): boolean;
90
131
  /**
91
132
  * Generate one Playwright test per surface × width that captures the style
92
133
  * map to `<baseDir>/<dir>/<key>@<width>.json.gz`. Captures are made
@@ -100,4 +141,4 @@ export declare function passLiveStreams(page: Page, url: string): Promise<void>;
100
141
  * defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
101
142
  * ```
102
143
  */
103
- export declare function defineStyleMapCapture({ surfaces, dir, baseDir, screenshots, replayFrom, replayUrl, freezeClock, clockTime, selfCheck, captureText, }: DefineOptions): void;
144
+ export declare function defineStyleMapCapture({ surfaces, expected, exclude, dir, baseDir, screenshots, replayFrom, replayUrl, freezeClock, clockTime, selfCheck, captureText, captureComponent, }: DefineOptions): void;
package/dist/runner.js CHANGED
@@ -1,8 +1,9 @@
1
- import { test } from '@playwright/test';
1
+ import { test, expect } from '@playwright/test';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { captureStyleMap, saveStyleMap } from './capture.js';
4
+ import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
5
5
  import { diffStyleMaps } from './diff.js';
6
+ import { coverageGaps } from './coverage.js';
6
7
  /** One-line description of the first drift finding, for the self-check error. */
7
8
  function driftDesc(f) {
8
9
  if (f.kind === 'dom')
@@ -69,9 +70,9 @@ async function pinInputs(page, harName, s) {
69
70
  await page.clock.setFixedTime(new Date(s.clockTime));
70
71
  }
71
72
  /** Capture the surface again and throw if the computed styles drifted from `first`. */
72
- async function assertDeterministic(page, surface, first, captureText) {
73
+ async function assertDeterministic(page, surface, first, captureText, pending) {
73
74
  await surface.go(page);
74
- const again = await captureStyleMap(page, { ignore: surface.ignore ?? [], captureText });
75
+ const again = await captureStyleMap(page, { ignore: surface.ignore ?? [], captureText, pendingRequests: pending });
75
76
  const drift = diffStyleMaps(first, again);
76
77
  if (drift.length) {
77
78
  throw new Error(`styleproof self-check failed: ${surface.key} is non-deterministic — ` +
@@ -86,18 +87,42 @@ async function captureSurface(page, surface, width, s) {
86
87
  await pinInputs(page, `${surface.key}@${width}.har`, s);
87
88
  const height = typeof surface.height === 'function' ? surface.height(width) : (surface.height ?? 800);
88
89
  await page.setViewportSize({ width, height });
89
- await surface.go(page);
90
- const map = await captureStyleMap(page, { ignore: surface.ignore ?? [], captureText: s.captureText });
91
- if (s.selfCheck)
92
- await assertDeterministic(page, surface, map, s.captureText);
93
- const stem = path.join(s.baseDir, s.dir, `${surface.key}@${width}`);
94
- saveStyleMap(`${stem}.json.gz`, map);
95
- if (s.screenshots) {
96
- // captureStyleMap froze animations/transitions, so this is the same settled
97
- // state the map describes.
98
- await page.screenshot({ path: `${stem}.png`, fullPage: true, animations: 'disabled' });
90
+ // Arm the in-flight request tracker BEFORE go() so the surface's own load fetches
91
+ // count toward the network-aware settle a request that starts during navigation
92
+ // fired its event before captureStyleMap could attach a listener of its own.
93
+ const requests = trackInflightRequests(page);
94
+ try {
95
+ await surface.go(page);
96
+ const map = await captureStyleMap(page, {
97
+ ignore: surface.ignore ?? [],
98
+ captureText: s.captureText,
99
+ captureComponent: s.captureComponent,
100
+ pendingRequests: requests.pending,
101
+ });
102
+ if (s.selfCheck)
103
+ await assertDeterministic(page, surface, map, s.captureText, requests.pending);
104
+ const stem = path.join(s.baseDir, s.dir, `${surface.key}@${width}`);
105
+ saveStyleMap(`${stem}.json.gz`, map);
106
+ if (s.screenshots) {
107
+ // captureStyleMap froze animations/transitions, so this is the same settled
108
+ // state the map describes.
109
+ await page.screenshot({ path: `${stem}.png`, fullPage: true, animations: 'disabled' });
110
+ }
111
+ }
112
+ finally {
113
+ requests.dispose();
99
114
  }
100
115
  }
116
+ /**
117
+ * Default for `selfCheck` when the consumer didn't set it: ON when RECORDING (no
118
+ * `replayFrom`) — that's where live nondeterminism surfaces — and OFF when REPLAYING,
119
+ * since the replay run renders against the recorded HAR and is deterministic by
120
+ * construction, so self-checking it just doubles the work. `STYLEPROOF_SELFCHECK=1`
121
+ * forces it on either way.
122
+ */
123
+ export function defaultSelfCheck(replayFrom, env = process.env.STYLEPROOF_SELFCHECK) {
124
+ return env === '1' || !replayFrom;
125
+ }
101
126
  /**
102
127
  * Generate one Playwright test per surface × width that captures the style
103
128
  * map to `<baseDir>/<dir>/<key>@<width>.json.gz`. Captures are made
@@ -111,8 +136,7 @@ async function captureSurface(page, surface, width, s) {
111
136
  * defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
112
137
  * ```
113
138
  */
114
- export function defineStyleMapCapture({ surfaces, 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 = process.env.STYLEPROOF_SELFCHECK === '1', captureText = false, }) {
115
- test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
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, }) {
116
140
  const settings = {
117
141
  dir: dir,
118
142
  baseDir,
@@ -123,8 +147,27 @@ export function defineStyleMapCapture({ surfaces, dir, baseDir = '__stylemaps__'
123
147
  clockTime,
124
148
  selfCheck,
125
149
  captureText,
150
+ captureComponent,
126
151
  };
152
+ // Coverage guard. Runs in the NORMAL test suite (NOT gated on a capture dir), so
153
+ // a route added without a surface fails the app's own tests — long before, and
154
+ // independent of, a capture run. This is the one gap captures can't catch: a
155
+ // surface never taken can't be diffed. Only emitted when the spec declares its
156
+ // `expected` universe; otherwise StyleProof keeps its prior behaviour exactly.
157
+ if (expected) {
158
+ test.describe('styleproof coverage', () => {
159
+ test('every expected surface is captured or explicitly excluded', () => {
160
+ const { uncovered, staleExclusions } = coverageGaps(surfaces.map((s) => s.key), expected, exclude);
161
+ expect(uncovered, `StyleProof coverage gap: ${uncovered.length} expected surface(s) are neither captured ` +
162
+ `nor excluded — add each to \`surfaces\`, or to \`exclude\` with a reason. ` +
163
+ `Missing: ${uncovered.join(', ')}`).toEqual([]);
164
+ expect(staleExclusions, `StyleProof: \`exclude\` lists surface(s) absent from \`expected\` ` +
165
+ `(renamed or removed?): ${staleExclusions.join(', ')}`).toEqual([]);
166
+ });
167
+ });
168
+ }
127
169
  test.describe('styleproof capture', () => {
170
+ test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
128
171
  for (const surface of surfaces) {
129
172
  for (const width of surface.widths) {
130
173
  test(`${surface.key} @ ${width}`, ({ page }) => captureSurface(page, surface, width, settings));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "2.0.0",
3
+ "version": "2.2.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": "^25.9.3",
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",