styleproof 1.8.0 → 1.9.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 +17 -0
- package/README.md +10 -3
- package/dist/report.js +15 -5
- package/dist/runner.d.ts +38 -2
- package/dist/runner.js +84 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.9.0]
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **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.
|
|
15
|
+
- **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.
|
|
16
|
+
- **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.
|
|
17
|
+
|
|
18
|
+
## [1.8.1]
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- The crop annotation now boxes the **innermost** changed elements (the added
|
|
23
|
+
avatars, the restyled cards) instead of the container the crop anchors on —
|
|
24
|
+
whose box just traced the whole frame and told you nothing. An element present
|
|
25
|
+
on only one side (added/removed) is boxed only there.
|
|
26
|
+
|
|
10
27
|
## [1.8.0]
|
|
11
28
|
|
|
12
29
|
The crop now shows you where to look — without painting over the UI.
|
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,13 @@ jobs:
|
|
|
121
122
|
|
|
122
123
|
**4. Require the `StyleProof` status** in branch protection. Now an unsigned visual change can't merge.
|
|
123
124
|
|
|
124
|
-
|
|
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
|
+
|
|
131
|
+
> 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
132
|
|
|
126
133
|
**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
134
|
|
package/dist/report.js
CHANGED
|
@@ -20,10 +20,17 @@ const union = (a, b) => {
|
|
|
20
20
|
};
|
|
21
21
|
const intersects = (a, b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
|
22
22
|
const visible = (b) => !!b && b.w > 0 && b.h > 0;
|
|
23
|
-
/** Outermost changed paths: drop any path that has a changed strict ancestor.
|
|
23
|
+
/** Outermost changed paths: drop any path that has a changed strict ancestor.
|
|
24
|
+
* Used to ANCHOR a crop (zoom to the whole changed region, not a leaf). */
|
|
24
25
|
function outermost(paths) {
|
|
25
26
|
return paths.filter((p) => !paths.some((q) => q !== p && p.startsWith(q + ' > ')));
|
|
26
27
|
}
|
|
28
|
+
/** Innermost changed paths: drop any path that has a changed strict descendant.
|
|
29
|
+
* Used to ANNOTATE — box the leaf elements that actually changed (the added
|
|
30
|
+
* avatars, the restyled cards), not their container, whose box ≈ the whole crop. */
|
|
31
|
+
function innermost(paths) {
|
|
32
|
+
return paths.filter((p) => !paths.some((q) => q !== p && q.startsWith(p + ' > ')));
|
|
33
|
+
}
|
|
27
34
|
/** Headline counts with the zeros dropped — `0 state-delta difference(s)` is noise. */
|
|
28
35
|
function changeCountLabel(shown) {
|
|
29
36
|
const parts = [];
|
|
@@ -748,10 +755,13 @@ export function generateStyleMapReport(opts) {
|
|
|
748
755
|
const after = cropPng(pngB, cropBox, w, h);
|
|
749
756
|
const composite = compositePair(before.png, after.png);
|
|
750
757
|
writePng(path.join(outDir, `${stem}-composite.png`), composite);
|
|
751
|
-
// Annotated twin: outline
|
|
752
|
-
//
|
|
753
|
-
|
|
754
|
-
|
|
758
|
+
// Annotated twin: outline the LEAF changed elements (the added avatars, the
|
|
759
|
+
// restyled cards) on each side — not the merged container the crop anchors
|
|
760
|
+
// on, whose box would just trace the whole frame. An element present on only
|
|
761
|
+
// one side (added/removed) is boxed only there.
|
|
762
|
+
const markPaths = innermost([...new Set(regionFindings.map((f) => f.path))]);
|
|
763
|
+
const rectsA = markPaths.map((p) => mapA.elements[p]?.rect).filter((r) => !!r);
|
|
764
|
+
const rectsB = markPaths.map((p) => mapB.elements[p]?.rect).filter((r) => !!r);
|
|
755
765
|
const annotated = compositePair(annotateCrop(before, rectsA), annotateCrop(after, rectsB));
|
|
756
766
|
writePng(path.join(outDir, `${stem}-annotated.png`), annotated);
|
|
757
767
|
images = { composite: `${stem}-composite.png`, annotated: `${stem}-annotated.png` };
|
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}`,
|
|
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.
|
|
3
|
+
"version": "1.9.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",
|