styleproof 1.8.1 → 1.9.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,20 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.9.1]
11
+
12
+ ### Fixed
13
+
14
+ - **Framework / non-visual DOM noise no longer registers as a change.** Capture now skips a built-in default set of selectors — `<meta>`, `<title>`, `<link>`, `<script>`, `<style>`, `<base>`, `<noscript>`, `<template>`, and `next-route-announcer` — merged into (not replaced by) the caller's `ignore`. These are elements frameworks stream into the body and reorder (Next.js app-router injects metadata then hoists it) or inject as live regions (Next's a11y route announcer), with no visual box to diff; their churn was surfacing as phantom DOM-added/removed findings on PRs that changed no CSS. A real stylesheet change still shows up in the affected elements' computed styles, not in the `<style>` tag. _Note: this changes the captured element set slightly — re-baseline once after upgrading._
15
+
16
+ ## [1.9.0]
17
+
18
+ ### Added
19
+
20
+ - **Deterministic captures with no per-repo fixtures.** `defineStyleMapCapture` now records each surface's data responses on the baseline run and replays them on the comparison run, so a diff reflects code, not live-data drift — a backend blip or a flipped status chip no longer reads as a style change on a PR that touched no CSS. Set `STYLEPROOF_REPLAY_FROM=<base dir>` on the head capture; only data URLs (`**/api/**`, configurable via `replayUrl` / `STYLEPROOF_REPLAY_URL`) are intercepted, so the app's own JS/CSS still load live and the captured run runs its own code. If the backend is down during a run, both sides replay the same recording — no phantom diff.
21
+ - **Frozen clock during capture** (`freezeClock`, on by default; `clockTime` to set the instant). Pins `Date.now()` / `new Date()` so time-derived styling (relative-age classes, "stale > 1h" flags) can't drift between runs; timers keep running so settling/polling still work.
22
+ - **Capture self-check** (`STYLEPROOF_SELFCHECK=1` / `selfCheck`). Captures each surface twice and fails with a clear _"non-deterministic capture"_ error (naming the drifting element) if the two differ — so a replay gap or unseeded randomness is caught at setup time instead of surfacing as a phantom change on an unrelated PR.
23
+
10
24
  ## [1.8.1]
11
25
 
12
26
  ### Fixed
package/README.md CHANGED
@@ -103,11 +103,12 @@ jobs:
103
103
  - run: npx wait-on http://localhost:3000
104
104
  - run: STYLEMAP_DIR=base npx playwright test e2e/styleproof.spec.ts
105
105
 
106
- # capture the PR head
106
+ # capture the PR head — replay the base's recorded data so the diff is
107
+ # code, not live-data drift (see "Deterministic by default" below)
107
108
  - run: git checkout ${{ github.event.pull_request.head.sha }}
108
109
  - run: npm ci && npm run build && (npm run serve &)
109
110
  - run: npx wait-on http://localhost:3000
110
- - run: STYLEMAP_DIR=head npx playwright test e2e/styleproof.spec.ts
111
+ - run: STYLEMAP_DIR=head STYLEPROOF_REPLAY_FROM=__stylemaps__/base npx playwright test e2e/styleproof.spec.ts
111
112
 
112
113
  # report + gate
113
114
  - uses: BenSheridanEdwards/styleproof@v1
@@ -121,7 +122,14 @@ jobs:
121
122
 
122
123
  **4. Require the `StyleProof` status** in branch protection. Now an unsigned visual change can't merge.
123
124
 
124
- > Capture both sides in the **same environment** (same machine, same env vars): if env vars change _what renders_, base and head will diff on DOM no PR touched.
125
+ **Deterministic by default — no fixtures required.** A style diff only means something if both sides saw the same inputs; otherwise live-data drift (a backend blip, a `5m ago` timestamp, a status chip that flips) reads as a style change on a PR that touched no CSS. StyleProof handles this for you:
126
+
127
+ - **Record / replay.** The base capture records each surface's data responses (anything matching `**/api/**`) to a HAR; the head capture replays them, so the head renders _its_ code against the _base's_ data — the app's own JS/CSS still load live. Backend down during a run? Both sides replay the same recording, so there's no phantom diff. Point the head capture at the base's recording with `STYLEPROOF_REPLAY_FROM=<base dir>` (see the CI step above); tune the data boundary with `STYLEPROOF_REPLAY_URL` / `replayUrl` if your API isn't under `/api`.
128
+ - **Frozen clock.** `Date.now()` / `new Date()` are pinned to a fixed instant, so time-derived styling (`stale > 1h → red`) can't drift. Timers keep running, so settling still works.
129
+ - **Self-check** (`STYLEPROOF_SELFCHECK=1`). Captures each surface twice and fails if they differ — a replay gap or unseeded randomness surfaces as a clear _"non-deterministic capture"_ error, never as a phantom change on an unrelated PR.
130
+ - **Framework noise is skipped by default.** Non-visual and framework-injected elements never count as a change — `<meta>`/`<title>`/`<script>`/`<style>`/… (which Next.js streams into the body then hoists) and live regions like Next's `next-route-announcer`. A real stylesheet change still shows up in the affected elements' computed styles, not in the `<style>` tag. Add your own selectors with `ignore` — they extend this default, they don't replace it.
131
+
132
+ > Replay covers data the page _fetches_. If your app **server-renders** differently per environment (SSR feature flags, locale), still capture both sides with the same server env so the rendered HTML matches.
125
133
 
126
134
  **Live pages just work.** Before each capture, StyleProof settles the page — it waits until the computed-style map stops changing, so async content (a fetch, an SSE/WebSocket stream backfilling a grid) is captured loaded, not mid-load. Anything still moving on its own after that is detected as a live region and excluded from the diff, so a stream or ticker never reads as a change — no manual `ignore` needed. Disable or tune with `captureStyleMap(page, { stabilize: false })` / `{ stabilize: { quietFor, timeout } }`.
127
135
 
package/dist/capture.d.ts CHANGED
@@ -65,9 +65,11 @@ export type StyleMap = {
65
65
  export type CaptureOptions = {
66
66
  /**
67
67
  * Selectors for nondeterministic regions (live data, embeds, ads). The
68
- * matching elements and their descendants are skipped entirely. Usually
69
- * unnecessary now that `stabilize` auto-detects live regions; use it to skip
70
- * a region you know is volatile without paying the settle wait for it.
68
+ * matching elements and their descendants are skipped entirely. Added to a
69
+ * built-in default that skips framework/non-visual noise (`<meta>`/`<title>`/
70
+ * `<script>`/`<style>`/… and `next-route-announcer`), so you rarely need it
71
+ * `stabilize` also auto-detects live regions. Use it to skip a region you know
72
+ * is volatile without paying the settle wait for it.
71
73
  */
72
74
  ignore?: string[];
73
75
  /**
package/dist/capture.js CHANGED
@@ -5,6 +5,26 @@ const INTERACTIVE = 'a, button, input, textarea, select, summary, [role="button"
5
5
  // Freeze motion so every captured value is a settled end state, not a frame
6
6
  // of an animation or a mid-flight transition after a forced :hover.
7
7
  const FREEZE_CSS = '*,*::before,*::after{animation:none!important;transition:none!important}';
8
+ // Always skipped, merged into the caller's `ignore`. Two kinds of noise that
9
+ // are never a visual change worth gating on but churn the DOM between runs:
10
+ // - non-rendered elements frameworks stream into <body> then hoist (Next.js
11
+ // app-router injects <meta>/<title>/<link>); they have no box to style, and
12
+ // their presence/order is nondeterministic. A real stylesheet change still
13
+ // shows up in the affected elements' computed styles, not in the <style> tag.
14
+ // - framework-injected live regions / overlays that mount and reorder on their
15
+ // own (Next.js's a11y route announcer — already a known source of CDP skew).
16
+ const FRAMEWORK_IGNORE = [
17
+ 'meta',
18
+ 'title',
19
+ 'link',
20
+ 'script',
21
+ 'style',
22
+ 'base',
23
+ 'noscript',
24
+ 'template',
25
+ 'next-route-announcer',
26
+ '[id="__next-route-announcer__"]',
27
+ ];
8
28
  /** True if `path` is one of `roots` or a structural descendant of one. Shared by
9
29
  * the capture (excluding live regions) and the diff (skipping them). */
10
30
  export function isUnder(path, roots) {
@@ -328,6 +348,51 @@ function capturePageTokens() {
328
348
  probe.remove();
329
349
  return tokens;
330
350
  }
351
+ /** Settle the page and return the paths of live regions to exclude. */
352
+ async function detectVolatile(page, ignore, stabilize) {
353
+ if (stabilize === false)
354
+ return [];
355
+ const opt = typeof stabilize === 'object' ? stabilize : {};
356
+ const volatile = await stabilizePage(page, ignore, opt.interval || 150, opt.quietFor || 600, opt.timeout || 5000);
357
+ if (volatile.length) {
358
+ // eslint-disable-next-line no-console
359
+ console.warn(`styleproof: ${volatile.length} live region(s) kept changing on their own and were excluded from ` +
360
+ 'this capture (nondeterministic — a stream, ticker, or late-loading content). The diff skips them so ' +
361
+ 'they never read as a change. If a real change is being hidden, settle the page in go() or raise stabilize.timeout.');
362
+ }
363
+ return volatile;
364
+ }
365
+ /** Drop live regions (and their subtrees) from the base capture, in Node so the
366
+ * serialized capturePage stays a pure snapshot. */
367
+ function dropVolatile(elements, volatile) {
368
+ if (!volatile.length)
369
+ return;
370
+ for (const p of Object.keys(elements))
371
+ if (isUnder(p, volatile))
372
+ delete elements[p];
373
+ }
374
+ /** Warn once when shadow roots / iframes were skipped (their styles aren't captured or diffed). */
375
+ function warnUntraversed(shadowHosts, sameOriginFrames) {
376
+ if (!shadowHosts && !sameOriginFrames)
377
+ return;
378
+ // eslint-disable-next-line no-console
379
+ console.warn(`styleproof: ${shadowHosts} shadow host(s) and ${sameOriginFrames} same-origin iframe(s) were ` +
380
+ 'NOT traversed — styles inside shadow roots and frames are not captured or diffed. A refactor inside ' +
381
+ 'one would be reported as identical. See README "Limitations".');
382
+ }
383
+ /** Fold the pre-freeze motion longhands back onto the settled base capture. */
384
+ function mergeMotion(elements, motion) {
385
+ for (const [p, entry] of Object.entries(elements)) {
386
+ const m = motion[p];
387
+ if (!m)
388
+ continue;
389
+ Object.assign(entry.style, m.style);
390
+ for (const [ps, props] of Object.entries(m.pseudo ?? {})) {
391
+ if (entry.pseudo?.[ps])
392
+ Object.assign(entry.pseudo[ps], props);
393
+ }
394
+ }
395
+ }
331
396
  /**
332
397
  * Capture the page's complete style map. Drive the page to the state you want
333
398
  * first (navigate, open menus); by default the capture then auto-settles the
@@ -335,7 +400,9 @@ function capturePageTokens() {
335
400
  * paints after `go()` resolves is captured loaded, not mid-load.
336
401
  */
337
402
  export async function captureStyleMap(page, options = {}) {
338
- const ignore = options.ignore ?? [];
403
+ // Framework/non-visual noise is always skipped, so it can't read as a DOM
404
+ // change; the caller's `ignore` adds to it (not replaces it).
405
+ const ignore = [...FRAMEWORK_IGNORE, ...(options.ignore ?? [])];
339
406
  const captureStates = options.captureStates ?? true;
340
407
  const maxInteractive = options.maxInteractive ?? 800;
341
408
  const stabilize = options.stabilize ?? true;
@@ -346,43 +413,11 @@ export async function captureStyleMap(page, options = {}) {
346
413
  // the same loaded state, and collect any region still changing on its own
347
414
  // (a live stream/ticker) to exclude — animations are frozen above, so only
348
415
  // real content/layout churn lands here.
349
- let volatile = [];
350
- if (stabilize !== false) {
351
- const opt = typeof stabilize === 'object' ? stabilize : {};
352
- const interval = opt.interval || 150;
353
- const quietFor = opt.quietFor || 600;
354
- const timeout = opt.timeout || 5000;
355
- volatile = await stabilizePage(page, ignore, interval, quietFor, timeout);
356
- if (volatile.length) {
357
- // eslint-disable-next-line no-console
358
- console.warn(`styleproof: ${volatile.length} live region(s) kept changing on their own and were excluded from ` +
359
- 'this capture (nondeterministic — a stream, ticker, or late-loading content). The diff skips them so ' +
360
- 'they never read as a change. If a real change is being hidden, settle the page in go() or raise stabilize.timeout.');
361
- }
362
- }
416
+ const volatile = await detectVolatile(page, ignore, stabilize);
363
417
  const base = await page.evaluate(capturePage, { ignore, motionOnly: false });
364
- // Drop live regions (and their subtrees) detected by the settle pass — done
365
- // here, in Node, so the serialized capturePage stays a pure snapshot.
366
- if (volatile.length)
367
- for (const p of Object.keys(base.elements))
368
- if (isUnder(p, volatile))
369
- delete base.elements[p];
370
- if (base.shadowHosts || base.sameOriginFrames) {
371
- // eslint-disable-next-line no-console
372
- console.warn(`styleproof: ${base.shadowHosts} shadow host(s) and ${base.sameOriginFrames} same-origin iframe(s) were ` +
373
- 'NOT traversed — styles inside shadow roots and frames are not captured or diffed. A refactor inside ' +
374
- 'one would be reported as identical. See README "Limitations".');
375
- }
376
- for (const [p, entry] of Object.entries(base.elements)) {
377
- const m = motion.elements[p];
378
- if (!m)
379
- continue;
380
- Object.assign(entry.style, m.style);
381
- for (const [ps, props] of Object.entries(m.pseudo ?? {})) {
382
- if (entry.pseudo?.[ps])
383
- Object.assign(entry.pseudo[ps], props);
384
- }
385
- }
418
+ dropVolatile(base.elements, volatile);
419
+ warnUntraversed(base.shadowHosts, base.sameOriginFrames);
420
+ mergeMotion(base.elements, motion.elements);
386
421
  let states = {};
387
422
  let statesSkipped = false;
388
423
  if (captureStates) {
package/dist/runner.d.ts CHANGED
@@ -32,14 +32,50 @@ export type DefineOptions = {
32
32
  * without screenshots still diff, but produce text-only reports.
33
33
  */
34
34
  screenshots?: boolean;
35
+ /**
36
+ * Replay a baseline run's recorded responses so a before/after diff reflects
37
+ * code, not live-data drift. When set (or via STYLEPROOF_REPLAY_FROM), each
38
+ * surface replays `<replayFrom>/<key>@<width>.har` for requests matching
39
+ * `replayUrl`; otherwise the run RECORDS that HAR into its own dir for the
40
+ * comparison run to replay. Only data URLs are intercepted, so the app's own
41
+ * JS/CSS still load live — the captured run renders ITS code against the
42
+ * baseline's data. This is what makes captures deterministic with no per-repo
43
+ * fixtures: record once on the base, replay on the head.
44
+ */
45
+ replayFrom?: string;
46
+ /**
47
+ * URL glob for the data boundary to record/replay (default `**\/api/**`, or
48
+ * STYLEPROOF_REPLAY_URL). Requests outside it (JS/CSS/fonts/images) always
49
+ * load live so the captured code actually runs.
50
+ */
51
+ replayUrl?: string;
52
+ /**
53
+ * Freeze `Date.now()`/`new Date()` to a fixed instant so time-derived styling
54
+ * (relative-age classes, "stale > 1h" flags) can't drift between runs. Timers
55
+ * keep running, so settling/polling still works. Default true.
56
+ */
57
+ freezeClock?: boolean;
58
+ /** Fixed instant for the frozen clock (default `2025-01-01T00:00:00Z`). */
59
+ clockTime?: string | number | Date;
60
+ /**
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.
65
+ */
66
+ selfCheck?: boolean;
35
67
  };
36
68
  /**
37
69
  * Generate one Playwright test per surface × width that captures the style
38
- * map to `<baseDir>/<dir>/<key>@<width>.json.gz`.
70
+ * map to `<baseDir>/<dir>/<key>@<width>.json.gz`. Captures are made
71
+ * deterministic with no per-repo fixtures: the baseline run records each
72
+ * surface's data responses to a HAR, and the comparison run replays them (set
73
+ * STYLEPROOF_REPLAY_FROM=<baseline dir> on the comparison capture), while the
74
+ * clock is frozen so time-derived styling is stable.
39
75
  *
40
76
  * ```ts
41
77
  * // styleproof.spec.ts
42
78
  * defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
43
79
  * ```
44
80
  */
45
- export declare function defineStyleMapCapture({ surfaces, dir, baseDir, screenshots, }: DefineOptions): void;
81
+ export declare function defineStyleMapCapture({ surfaces, dir, baseDir, screenshots, replayFrom, replayUrl, freezeClock, clockTime, selfCheck, }: DefineOptions): void;
package/dist/runner.js CHANGED
@@ -1,34 +1,102 @@
1
1
  import { test } from '@playwright/test';
2
+ import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { captureStyleMap, saveStyleMap } from './capture.js';
5
+ import { diffStyleMaps } from './diff.js';
6
+ /** One-line description of the first drift finding, for the self-check error. */
7
+ function driftDesc(f) {
8
+ if (f.kind === 'dom')
9
+ return `${f.path} ${f.change}`;
10
+ const p = f.props[0];
11
+ return p ? `${f.path} ${p.prop}: ${p.before} → ${p.after}` : f.path;
12
+ }
13
+ /**
14
+ * Pin the surface's inputs: replay the baseline's recorded data (or record ours),
15
+ * scoped to the data URLs so the app's own JS/CSS still load live, then freeze the
16
+ * clock so time-derived styling is stable.
17
+ */
18
+ async function pinInputs(page, harName, s) {
19
+ if (s.replayFrom) {
20
+ const har = path.join(s.replayFrom, harName);
21
+ if (fs.existsSync(har)) {
22
+ // notFound:'abort' — a request the baseline never recorded fails
23
+ // deterministically rather than silently hitting the live backend.
24
+ await page.routeFromHAR(har, { url: s.replayUrl, update: false, notFound: 'abort' });
25
+ }
26
+ else {
27
+ // eslint-disable-next-line no-console
28
+ console.warn(`styleproof: no replay HAR at ${har} — capturing live (NON-deterministic)`);
29
+ }
30
+ }
31
+ else {
32
+ await page.routeFromHAR(path.join(s.baseDir, s.dir, harName), {
33
+ url: s.replayUrl,
34
+ update: true,
35
+ updateContent: 'embed', // single portable file, no sidecar resources
36
+ });
37
+ }
38
+ if (s.freezeClock)
39
+ await page.clock.setFixedTime(new Date(s.clockTime));
40
+ }
41
+ /** Capture the surface again and throw if the computed styles drifted from `first`. */
42
+ async function assertDeterministic(page, surface, first) {
43
+ await surface.go(page);
44
+ const again = await captureStyleMap(page, { ignore: surface.ignore ?? [] });
45
+ const drift = diffStyleMaps(first, again);
46
+ if (drift.length) {
47
+ throw new Error(`styleproof self-check failed: ${surface.key} is non-deterministic — ` +
48
+ `${drift.length} computed-style difference(s) between two captures of the same commit. ` +
49
+ `Likely a replay gap (a request not in the baseline HAR) or unseeded randomness. ` +
50
+ `First: ${driftDesc(drift[0])}`);
51
+ }
52
+ }
53
+ /** Drive one surface at one width to a settled state and save its style map (+ screenshot). */
54
+ async function captureSurface(page, surface, width, s) {
55
+ test.setTimeout(180_000);
56
+ await pinInputs(page, `${surface.key}@${width}.har`, s);
57
+ const height = typeof surface.height === 'function' ? surface.height(width) : (surface.height ?? 800);
58
+ await page.setViewportSize({ width, height });
59
+ await surface.go(page);
60
+ const map = await captureStyleMap(page, { ignore: surface.ignore ?? [] });
61
+ if (s.selfCheck)
62
+ await assertDeterministic(page, surface, map);
63
+ const stem = path.join(s.baseDir, s.dir, `${surface.key}@${width}`);
64
+ saveStyleMap(`${stem}.json.gz`, map);
65
+ if (s.screenshots) {
66
+ // captureStyleMap froze animations/transitions, so this is the same settled
67
+ // state the map describes.
68
+ await page.screenshot({ path: `${stem}.png`, fullPage: true, animations: 'disabled' });
69
+ }
70
+ }
4
71
  /**
5
72
  * Generate one Playwright test per surface × width that captures the style
6
- * map to `<baseDir>/<dir>/<key>@<width>.json.gz`.
73
+ * map to `<baseDir>/<dir>/<key>@<width>.json.gz`. Captures are made
74
+ * deterministic with no per-repo fixtures: the baseline run records each
75
+ * surface's data responses to a HAR, and the comparison run replays them (set
76
+ * STYLEPROOF_REPLAY_FROM=<baseline dir> on the comparison capture), while the
77
+ * clock is frozen so time-derived styling is stable.
7
78
  *
8
79
  * ```ts
9
80
  * // styleproof.spec.ts
10
81
  * defineStyleMapCapture({ surfaces: SURFACES, dir: process.env.STYLEMAP_DIR });
11
82
  * ```
12
83
  */
13
- export function defineStyleMapCapture({ surfaces, dir, baseDir = '__stylemaps__', screenshots = true, }) {
84
+ 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', }) {
14
85
  test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
86
+ const settings = {
87
+ dir: dir,
88
+ baseDir,
89
+ screenshots,
90
+ replayFrom,
91
+ replayUrl,
92
+ freezeClock,
93
+ clockTime,
94
+ selfCheck,
95
+ };
15
96
  test.describe('styleproof capture', () => {
16
97
  for (const surface of surfaces) {
17
98
  for (const width of surface.widths) {
18
- test(`${surface.key} @ ${width}`, async ({ page }) => {
19
- test.setTimeout(180_000);
20
- const height = typeof surface.height === 'function' ? surface.height(width) : (surface.height ?? 800);
21
- await page.setViewportSize({ width, height });
22
- await surface.go(page);
23
- const map = await captureStyleMap(page, { ignore: surface.ignore ?? [] });
24
- const stem = path.join(baseDir, dir, `${surface.key}@${width}`);
25
- saveStyleMap(`${stem}.json.gz`, map);
26
- if (screenshots) {
27
- // captureStyleMap froze animations/transitions, so this is the
28
- // same settled state the map describes.
29
- await page.screenshot({ path: `${stem}.png`, fullPage: true, animations: 'disabled' });
30
- }
31
- });
99
+ test(`${surface.key} @ ${width}`, ({ page }) => captureSurface(page, surface, width, settings));
32
100
  }
33
101
  }
34
102
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "1.8.1",
3
+ "version": "1.9.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",