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/CHANGELOG.md +78 -0
- package/README.md +101 -22
- package/bin/styleproof-init.mjs +132 -45
- package/dist/capture.d.ts +62 -5
- package/dist/capture.js +156 -37
- package/dist/coverage.d.ts +35 -0
- package/dist/coverage.js +35 -0
- package/dist/diff.d.ts +4 -0
- package/dist/diff.js +30 -2
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/report.js +54 -18
- package/dist/routes.d.ts +29 -0
- package/dist/routes.js +84 -0
- package/dist/runner.d.ts +47 -6
- package/dist/runner.js +59 -16
- package/package.json +2 -2
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
|
-
/**
|
|
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
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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 =
|
|
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.
|
|
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": "^
|
|
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",
|