styleproof 1.3.1 → 1.5.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 +64 -0
- package/README.md +3 -0
- package/bin/styleproof-diff.mjs +15 -6
- package/bin/styleproof-report.mjs +6 -3
- package/dist/capture.d.ts +40 -4
- package/dist/capture.js +81 -8
- package/dist/diff.d.ts +3 -1
- package/dist/diff.js +34 -13
- package/dist/report.d.ts +3 -0
- package/dist/report.js +51 -7
- package/package.json +9 -7
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,70 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.5.0]
|
|
11
|
+
|
|
12
|
+
Live regions are handled automatically, so a dashboard with streaming data,
|
|
13
|
+
tickers, or late-loading content no longer produces a false "everything
|
|
14
|
+
changed" report.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **Auto-settle before capture (`stabilize`, default on).** The capture now
|
|
19
|
+
polls the (motion-frozen) page until its computed-style map has been unchanged
|
|
20
|
+
for a quiet window, so content that paints _after_ `go()` resolves — an async
|
|
21
|
+
fetch, an SSE/WebSocket stream backfilling a grid — is captured loaded, not
|
|
22
|
+
mid-load. This is what fixes the most common false positive: base and head
|
|
23
|
+
racing an async load and diffing empty-state-vs-populated. Requiring a
|
|
24
|
+
_sustained_ no-change window (not a single quiet sample) is what lets it wait
|
|
25
|
+
through the gap before late content appears.
|
|
26
|
+
- **Automatic live-region detection.** Anything still changing on its own when
|
|
27
|
+
the settle budget runs out is, by definition, nondeterministic (it mutates
|
|
28
|
+
with no code change). Those element paths are recorded in `StyleMap.volatile`
|
|
29
|
+
and excluded from the diff — unioned across both sides — so a stream or ticker
|
|
30
|
+
never reads as a change, with no manual `ignore`. The report notes how many
|
|
31
|
+
live regions were auto-excluded. Tune or disable via
|
|
32
|
+
`captureStyleMap(page, { stabilize })` (`false`, or `{ interval, quietFor, timeout }`).
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- `diffStyleMapDirs` now returns a `volatile` count alongside `surfaces`/`counts`.
|
|
37
|
+
- Text-only churn (a clock, "2m ago") still never matters — the diff has always
|
|
38
|
+
compared computed style, not text; this release adds the structural/layout
|
|
39
|
+
determinism to match.
|
|
40
|
+
|
|
41
|
+
## [1.4.0]
|
|
42
|
+
|
|
43
|
+
New surfaces (present on only one side, no baseline to diff) are shown for
|
|
44
|
+
reference and never block the review gate.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- **A surface captured on only one side is now treated as a _new surface_, not a
|
|
49
|
+
change.** Previously every one-sided surface counted as a DOM change, so a
|
|
50
|
+
bootstrap PR (base branch has no capture spec yet → empty baseline) rendered a
|
|
51
|
+
self-contradicting report — a `0 DOM change(s) · 0 … · 0 … across 0 distinct
|
|
52
|
+
change(s) in N surface(s)` headline sitting above N "re-run both captures"
|
|
53
|
+
warnings, each with an approval checkbox that could turn the gate green on a
|
|
54
|
+
capture set that has no baseline at all.
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
- **New surfaces are shown, not blocked.** A surface present on only one side is
|
|
59
|
+
rendered with its captured-side screenshot under a `🆕 new surface` heading,
|
|
60
|
+
summarised on its own headline line (`🆕 N new surface(s) … don't block the
|
|
61
|
+
check`), and excluded from the change tallies. In review-gate mode it gets an
|
|
62
|
+
**optional** `Approve this new surface` checkbox that the approve workflow
|
|
63
|
+
deliberately ignores — so a new surface never holds the `StyleProof` status red.
|
|
64
|
+
Real before↔after changes still gate exactly as before.
|
|
65
|
+
- **`styleproof-diff` exit codes:** `0` identical, `1` reviewable differences,
|
|
66
|
+
`2` usage/capture error (unchanged), and new **`3`** = only new surfaces (no
|
|
67
|
+
baseline to diff). The Action reports on `1` or `3` but only gates on `1`;
|
|
68
|
+
certify mode (`fail-on-diff`) still fails on either, since "nothing changed"
|
|
69
|
+
means the capture set didn't grow either.
|
|
70
|
+
- The Action `changed` output is now `"false"` for a brand-new surface with no
|
|
71
|
+
baseline (it was a change before). `generateStyleMapReport` returns a new
|
|
72
|
+
`newSurfaces` count alongside `changedSurfaces`.
|
|
73
|
+
|
|
10
74
|
## [1.3.1]
|
|
11
75
|
|
|
12
76
|
### Changed
|
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ On every PR, StyleProof captures a `StyleMap` from the HEAD and from the base br
|
|
|
16
16
|
|
|
17
17
|
- A summary line, then **one section per distinct change**, with a side-by-side before/after cropped screenshot and the property changes folded under a toggle.
|
|
18
18
|
- An **approval checkbox per change**, driving a `StyleProof` commit status: red until every change is signed off, green when there are none.
|
|
19
|
+
- **New surfaces don't block.** A surface that exists only on the PR head (no baseline to diff — e.g. the bootstrap PR that first adds the capture spec, or a brand-new page) is shown with its screenshot under a `🆕 new surface` heading and an _optional_ approval box, but it never holds the status red. It becomes part of the baseline once merged.
|
|
19
20
|
- No committed baseline to maintain — the diff is HEAD-vs-base, so the report is _exactly what this PR changes_.
|
|
20
21
|
|
|
21
22
|
## What a report looks like
|
|
@@ -122,6 +123,8 @@ jobs:
|
|
|
122
123
|
|
|
123
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.
|
|
124
125
|
|
|
126
|
+
**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
|
+
|
|
125
128
|
## Reference
|
|
126
129
|
|
|
127
130
|
**Action `BenSheridanEdwards/styleproof@v1`** — key inputs:
|
package/bin/styleproof-diff.mjs
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* `hover:` variant a screenshot can never catch.
|
|
15
15
|
*
|
|
16
16
|
* Custom properties (--*) are ignored: they are inputs, not outcomes (see
|
|
17
|
-
* README). Exit code 0 = identical, 1 = differences, 2 = usage/capture
|
|
17
|
+
* README). Exit code 0 = identical, 1 = reviewable differences, 2 = usage/capture
|
|
18
|
+
* error, 3 = only new surfaces (present on one side, no baseline to diff against).
|
|
18
19
|
*/
|
|
19
20
|
import fs from 'node:fs';
|
|
20
21
|
import { diffStyleMapDirs, findingLabel } from '../dist/diff.js';
|
|
@@ -28,7 +29,8 @@ options:
|
|
|
28
29
|
--json <file> also write the full structured diff to <file>
|
|
29
30
|
-h, --help show this help
|
|
30
31
|
|
|
31
|
-
exit: 0 identical (certified), 1 differences found, 2 usage/capture error
|
|
32
|
+
exit: 0 identical (certified), 1 differences found, 2 usage/capture error,
|
|
33
|
+
3 only new surfaces (present on one side, no baseline to diff against).
|
|
32
34
|
`;
|
|
33
35
|
|
|
34
36
|
const argv = process.argv.slice(2);
|
|
@@ -71,7 +73,8 @@ const { surfaces, counts } = result;
|
|
|
71
73
|
|
|
72
74
|
for (const sd of surfaces) {
|
|
73
75
|
if (sd.missing) {
|
|
74
|
-
|
|
76
|
+
const side = sd.missing === 'before' ? 'after' : 'before';
|
|
77
|
+
console.log(`\n${sd.surface}: new surface — captured only in the ${side} set, no baseline to compare`);
|
|
75
78
|
continue;
|
|
76
79
|
}
|
|
77
80
|
const lines = [];
|
|
@@ -98,13 +101,19 @@ for (const sd of surfaces) {
|
|
|
98
101
|
if (jsonOut) fs.writeFileSync(jsonOut, JSON.stringify({ counts, surfaces }, null, 2));
|
|
99
102
|
|
|
100
103
|
const total = counts.dom + counts.style + counts.state;
|
|
104
|
+
const newSurfaces = surfaces.filter((s) => s.missing).length;
|
|
101
105
|
const surfaceCount = new Set([
|
|
102
106
|
...fs.readdirSync(dirA).filter((f) => /\.json(\.gz)?$/.test(f)),
|
|
103
107
|
...fs.readdirSync(dirB).filter((f) => /\.json(\.gz)?$/.test(f)),
|
|
104
108
|
]).size;
|
|
109
|
+
const newNote = newSurfaces ? ` (+${newSurfaces} new surface(s) with no baseline)` : '';
|
|
105
110
|
console.log(
|
|
106
111
|
total === 0
|
|
107
|
-
?
|
|
108
|
-
|
|
112
|
+
? newSurfaces === 0
|
|
113
|
+
? `\n✓ ${surfaceCount} surfaces identical: every computed style, pseudo-element, and hover/focus/active state matches`
|
|
114
|
+
: `\nℹ ${newSurfaces} new surface(s) captured with no baseline to compare — shown for reference, no reviewable change`
|
|
115
|
+
: `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}`,
|
|
109
116
|
);
|
|
110
|
-
|
|
117
|
+
// 0 = identical, 1 = reviewable differences, 3 = only new surfaces (no baseline,
|
|
118
|
+
// nothing to review). 2 stays reserved for usage/capture errors.
|
|
119
|
+
process.exit(total > 0 ? 1 : newSurfaces > 0 ? 3 : 0);
|
|
@@ -108,10 +108,13 @@ try {
|
|
|
108
108
|
process.exit(2);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
const newNote = result.newSurfaces ? ` (+${result.newSurfaces} new surface(s) with no baseline)` : '';
|
|
111
112
|
console.log(
|
|
112
113
|
result.changedSurfaces === 0
|
|
113
|
-
?
|
|
114
|
-
|
|
114
|
+
? result.newSurfaces === 0
|
|
115
|
+
? '✓ no changes — empty report written'
|
|
116
|
+
: `ℹ ${result.newSurfaces} new surface(s) with no baseline — report written for reference`
|
|
117
|
+
: `✗ ${result.changedSurfaces} changed surface(s), ${result.totalFindings} finding(s)${newNote}`,
|
|
115
118
|
);
|
|
116
119
|
console.log(`report: ${result.reportMdPath}`);
|
|
117
|
-
process.exit(result.changedSurfaces === 0 ? 0 : 1);
|
|
120
|
+
process.exit(result.changedSurfaces === 0 && result.newSurfaces === 0 ? 0 : 1);
|
package/dist/capture.d.ts
CHANGED
|
@@ -45,13 +45,45 @@ export type StyleMap = {
|
|
|
45
45
|
* certified, instead of silently reading as "identical".
|
|
46
46
|
*/
|
|
47
47
|
statesSkipped?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Element paths detected as LIVE at capture time — they kept changing on
|
|
50
|
+
* their own (after motion was frozen) until the settle budget ran out, so
|
|
51
|
+
* they're nondeterministic. Excluded from `elements`/`states`/`defaults`
|
|
52
|
+
* here, and skipped by the diff (which unions both sides' volatile sets) so a
|
|
53
|
+
* stream/ticker never reads as a change. Empty/absent on a fully-settled page.
|
|
54
|
+
*/
|
|
55
|
+
volatile?: string[];
|
|
48
56
|
};
|
|
49
57
|
export type CaptureOptions = {
|
|
50
58
|
/**
|
|
51
59
|
* Selectors for nondeterministic regions (live data, embeds, ads). The
|
|
52
|
-
* matching elements and their descendants are skipped entirely.
|
|
60
|
+
* matching elements and their descendants are skipped entirely. Usually
|
|
61
|
+
* unnecessary now that `stabilize` auto-detects live regions; use it to skip
|
|
62
|
+
* a region you know is volatile without paying the settle wait for it.
|
|
53
63
|
*/
|
|
54
64
|
ignore?: string[];
|
|
65
|
+
/**
|
|
66
|
+
* Settle the page before capturing, and auto-exclude live regions (default
|
|
67
|
+
* on). StyleProof polls the (motion-frozen) page until its computed-style map
|
|
68
|
+
* stops changing — so async content that paints AFTER `go()` resolves (a
|
|
69
|
+
* fetch, an SSE/WebSocket stream) is captured in its loaded state, not
|
|
70
|
+
* mid-load. Any region still changing when the budget runs out is a live
|
|
71
|
+
* region by definition (it mutates with no code change); its paths are
|
|
72
|
+
* recorded in `StyleMap.volatile` and excluded from the diff, so a stream or
|
|
73
|
+
* ticker never reads as a change — no manual `ignore` needed. Text-only churn
|
|
74
|
+
* (a clock, "2m ago") never matters: the diff compares computed style, not
|
|
75
|
+
* text. Pass `false` to capture the exact frame `go()` left, or `{ interval,
|
|
76
|
+
* quietFor, timeout }` (ms) to tune the poll cadence, the no-change window
|
|
77
|
+
* that counts as settled, and the budget. Note: content that first paints
|
|
78
|
+
* after a quiet gap longer than `quietFor` can't be waited for without a
|
|
79
|
+
* signal — settle that in `go()`; anything still moving at `timeout` is
|
|
80
|
+
* treated as a live region.
|
|
81
|
+
*/
|
|
82
|
+
stabilize?: boolean | {
|
|
83
|
+
interval?: number;
|
|
84
|
+
quietFor?: number;
|
|
85
|
+
timeout?: number;
|
|
86
|
+
};
|
|
55
87
|
/**
|
|
56
88
|
* Capture forced :hover/:focus/:active state deltas (default true). This is
|
|
57
89
|
* the expensive layer — O(interactive elements × 3 states) with a subtree
|
|
@@ -66,10 +98,14 @@ export type CaptureOptions = {
|
|
|
66
98
|
*/
|
|
67
99
|
maxInteractive?: number;
|
|
68
100
|
};
|
|
101
|
+
/** True if `path` is one of `roots` or a structural descendant of one. Shared by
|
|
102
|
+
* the capture (excluding live regions) and the diff (skipping them). */
|
|
103
|
+
export declare function isUnder(path: string, roots: string[]): boolean;
|
|
69
104
|
/**
|
|
70
|
-
* Capture the page's complete style map. Drive the page to the state you
|
|
71
|
-
*
|
|
72
|
-
*
|
|
105
|
+
* Capture the page's complete style map. Drive the page to the state you want
|
|
106
|
+
* first (navigate, open menus); by default the capture then auto-settles the
|
|
107
|
+
* page and excludes live regions (see `stabilize`), so a fetch/stream that
|
|
108
|
+
* paints after `go()` resolves is captured loaded, not mid-load.
|
|
73
109
|
*/
|
|
74
110
|
export declare function captureStyleMap(page: Page, options?: CaptureOptions): Promise<StyleMap>;
|
|
75
111
|
/** Write a style map to disk; gzipped when the path ends in `.gz`. */
|
package/dist/capture.js
CHANGED
|
@@ -5,6 +5,11 @@ 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
|
+
/** True if `path` is one of `roots` or a structural descendant of one. Shared by
|
|
9
|
+
* the capture (excluding live regions) and the diff (skipping them). */
|
|
10
|
+
export function isUnder(path, roots) {
|
|
11
|
+
return roots.some((r) => path === r || path.startsWith(r + ' > '));
|
|
12
|
+
}
|
|
8
13
|
// Serialized into the browser by page.evaluate; cannot call module helpers.
|
|
9
14
|
function capturePage({ ignore, motionOnly }) {
|
|
10
15
|
const MOTION = /^(transition|animation)/;
|
|
@@ -206,14 +211,16 @@ function pathsForSelector({ selector, skipSel }) {
|
|
|
206
211
|
}
|
|
207
212
|
// Forced pseudo-class states on interactive elements, via CDP so no real
|
|
208
213
|
// mouse or focus is involved and parent-state descendant rules still apply.
|
|
209
|
-
async function captureForcedStates(page, ignore, maxInteractive) {
|
|
214
|
+
async function captureForcedStates(page, ignore, maxInteractive, skipPaths = []) {
|
|
210
215
|
const client = await page.context().newCDPSession(page);
|
|
211
216
|
await client.send('DOM.enable');
|
|
212
217
|
await client.send('CSS.enable');
|
|
213
218
|
const { root } = await client.send('DOM.getDocument');
|
|
214
219
|
const { nodeIds } = await client.send('DOM.querySelectorAll', { nodeId: root.nodeId, selector: INTERACTIVE });
|
|
215
220
|
const skipSel = ignore.length ? ignore.map((s) => `${s}, ${s} *`).join(', ') : '';
|
|
216
|
-
|
|
221
|
+
// Null out forced-state work for live (volatile) paths too, so they're not
|
|
222
|
+
// probed and can't reintroduce the churn the settle pass just excluded.
|
|
223
|
+
const paths = (await page.evaluate(pathsForSelector, { selector: INTERACTIVE, skipSel })).map((p) => p && skipPaths.length && isUnder(p, skipPaths) ? null : p);
|
|
217
224
|
// The CDP DOM snapshot and the live querySelectorAll are two separate,
|
|
218
225
|
// non-atomic reads. They can legitimately disagree — display:contents,
|
|
219
226
|
// nodes detached or injected between the two calls (next-route-announcer,
|
|
@@ -254,19 +261,84 @@ async function captureForcedStates(page, ignore, maxInteractive) {
|
|
|
254
261
|
await client.detach();
|
|
255
262
|
return { states, skipped: false };
|
|
256
263
|
}
|
|
264
|
+
/** Element paths that differ between two captures (added, removed, or restyled). */
|
|
265
|
+
function changedElementPaths(a, b) {
|
|
266
|
+
const out = [];
|
|
267
|
+
for (const p of new Set([...Object.keys(a), ...Object.keys(b)]))
|
|
268
|
+
if (JSON.stringify(a[p]) !== JSON.stringify(b[p]))
|
|
269
|
+
out.push(p);
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
257
272
|
/**
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
273
|
+
* Poll the page until its computed-style map has been UNCHANGED for `quietFor`
|
|
274
|
+
* ms (it settled — async content finished painting) or the budget runs out.
|
|
275
|
+
* Requiring a sustained quiet window, not a single quiet sample, is what lets it
|
|
276
|
+
* wait THROUGH the gap before late content paints and through a streaming
|
|
277
|
+
* backfill instead of settling on the first lull. Returns the paths still
|
|
278
|
+
* changing at timeout: genuine LIVE regions, to be excluded from the capture.
|
|
279
|
+
* Reuses `capturePage` (motion already frozen by the caller), so only
|
|
280
|
+
* content/layout churn — not an animation frame — keeps it from settling.
|
|
281
|
+
*/
|
|
282
|
+
async function stabilizePage(page, ignore, interval, quietFor, timeout) {
|
|
283
|
+
const snap = async () => (await page.evaluate(capturePage, { ignore, motionOnly: false })).elements;
|
|
284
|
+
const start = Date.now();
|
|
285
|
+
let prev = await snap();
|
|
286
|
+
let lastChangeAt = start;
|
|
287
|
+
let recent = [];
|
|
288
|
+
while (Date.now() - start < timeout) {
|
|
289
|
+
await page.waitForTimeout(interval);
|
|
290
|
+
const cur = await snap();
|
|
291
|
+
const changed = changedElementPaths(prev, cur);
|
|
292
|
+
prev = cur;
|
|
293
|
+
if (changed.length) {
|
|
294
|
+
lastChangeAt = Date.now();
|
|
295
|
+
recent = changed;
|
|
296
|
+
}
|
|
297
|
+
else if (Date.now() - lastChangeAt >= quietFor) {
|
|
298
|
+
return []; // unchanged for the full quiet window → settled
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return recent; // never went quiet for quietFor within budget → still-moving paths are live
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Capture the page's complete style map. Drive the page to the state you want
|
|
305
|
+
* first (navigate, open menus); by default the capture then auto-settles the
|
|
306
|
+
* page and excludes live regions (see `stabilize`), so a fetch/stream that
|
|
307
|
+
* paints after `go()` resolves is captured loaded, not mid-load.
|
|
261
308
|
*/
|
|
262
309
|
export async function captureStyleMap(page, options = {}) {
|
|
263
310
|
const ignore = options.ignore ?? [];
|
|
264
311
|
const captureStates = options.captureStates ?? true;
|
|
265
312
|
const maxInteractive = options.maxInteractive ?? 800;
|
|
313
|
+
const stabilize = options.stabilize ?? true;
|
|
266
314
|
// Motion longhands first (FREEZE_CSS would null them), then everything else.
|
|
267
315
|
const motion = await page.evaluate(capturePage, { ignore, motionOnly: true });
|
|
268
316
|
await page.addStyleTag({ content: FREEZE_CSS });
|
|
317
|
+
// Settle: wait for async content to finish painting so base and head capture
|
|
318
|
+
// the same loaded state, and collect any region still changing on its own
|
|
319
|
+
// (a live stream/ticker) to exclude — animations are frozen above, so only
|
|
320
|
+
// real content/layout churn lands here.
|
|
321
|
+
let volatile = [];
|
|
322
|
+
if (stabilize !== false) {
|
|
323
|
+
const opt = typeof stabilize === 'object' ? stabilize : {};
|
|
324
|
+
const interval = opt.interval || 150;
|
|
325
|
+
const quietFor = opt.quietFor || 600;
|
|
326
|
+
const timeout = opt.timeout || 5000;
|
|
327
|
+
volatile = await stabilizePage(page, ignore, interval, quietFor, timeout);
|
|
328
|
+
if (volatile.length) {
|
|
329
|
+
// eslint-disable-next-line no-console
|
|
330
|
+
console.warn(`styleproof: ${volatile.length} live region(s) kept changing on their own and were excluded from ` +
|
|
331
|
+
'this capture (nondeterministic — a stream, ticker, or late-loading content). The diff skips them so ' +
|
|
332
|
+
'they never read as a change. If a real change is being hidden, settle the page in go() or raise stabilize.timeout.');
|
|
333
|
+
}
|
|
334
|
+
}
|
|
269
335
|
const base = await page.evaluate(capturePage, { ignore, motionOnly: false });
|
|
336
|
+
// Drop live regions (and their subtrees) detected by the settle pass — done
|
|
337
|
+
// here, in Node, so the serialized capturePage stays a pure snapshot.
|
|
338
|
+
if (volatile.length)
|
|
339
|
+
for (const p of Object.keys(base.elements))
|
|
340
|
+
if (isUnder(p, volatile))
|
|
341
|
+
delete base.elements[p];
|
|
270
342
|
if (base.shadowHosts || base.sameOriginFrames) {
|
|
271
343
|
// eslint-disable-next-line no-console
|
|
272
344
|
console.warn(`styleproof: ${base.shadowHosts} shadow host(s) and ${base.sameOriginFrames} same-origin iframe(s) were ` +
|
|
@@ -286,7 +358,7 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
286
358
|
let states = {};
|
|
287
359
|
let statesSkipped = false;
|
|
288
360
|
if (captureStates) {
|
|
289
|
-
const forced = await captureForcedStates(page, ignore, maxInteractive);
|
|
361
|
+
const forced = await captureForcedStates(page, ignore, maxInteractive, volatile);
|
|
290
362
|
states = forced.states;
|
|
291
363
|
statesSkipped = forced.skipped;
|
|
292
364
|
}
|
|
@@ -295,6 +367,7 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
295
367
|
elements: base.elements,
|
|
296
368
|
states,
|
|
297
369
|
...(statesSkipped ? { statesSkipped: true } : {}),
|
|
370
|
+
...(volatile.length ? { volatile } : {}),
|
|
298
371
|
};
|
|
299
372
|
}
|
|
300
373
|
/** Write a style map to disk; gzipped when the path ends in `.gz`. */
|
|
@@ -310,7 +383,7 @@ export function loadStyleMap(filePath) {
|
|
|
310
383
|
raw = fs.readFileSync(filePath);
|
|
311
384
|
}
|
|
312
385
|
catch (e) {
|
|
313
|
-
throw new Error(`styleproof: cannot read capture ${filePath}: ${e.message}
|
|
386
|
+
throw new Error(`styleproof: cannot read capture ${filePath}: ${e.message}`, { cause: e });
|
|
314
387
|
}
|
|
315
388
|
try {
|
|
316
389
|
const text = filePath.endsWith('.gz') ? gunzipSync(raw).toString('utf8') : raw.toString('utf8');
|
|
@@ -318,6 +391,6 @@ export function loadStyleMap(filePath) {
|
|
|
318
391
|
}
|
|
319
392
|
catch (e) {
|
|
320
393
|
throw new Error(`styleproof: capture ${filePath} is corrupt or truncated (${e.message}). ` +
|
|
321
|
-
'Re-capture it — a partial write or interrupted upload produces an unreadable .gz.');
|
|
394
|
+
'Re-capture it — a partial write or interrupted upload produces an unreadable .gz.', { cause: e });
|
|
322
395
|
}
|
|
323
396
|
}
|
package/dist/diff.d.ts
CHANGED
|
@@ -42,10 +42,12 @@ export type DiffCounts = {
|
|
|
42
42
|
};
|
|
43
43
|
/** Diff two style maps of the same surface. */
|
|
44
44
|
export declare function diffStyleMaps(a: StyleMap, b: StyleMap): Finding[];
|
|
45
|
-
/** Diff every same-named capture between two directories.
|
|
45
|
+
/** Diff every same-named capture between two directories. `volatile` is the
|
|
46
|
+
* count of live regions auto-excluded across all surfaces (union per surface). */
|
|
46
47
|
export declare function diffStyleMapDirs(dirA: string, dirB: string): {
|
|
47
48
|
surfaces: SurfaceDiff[];
|
|
48
49
|
counts: DiffCounts;
|
|
50
|
+
volatile: number;
|
|
49
51
|
};
|
|
50
52
|
/** Human label: structural path plus a truncated class hint. */
|
|
51
53
|
export declare function findingLabel(path: string, cls: string): string;
|
package/dist/diff.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { loadStyleMap } from './capture.js';
|
|
3
|
+
import { loadStyleMap, isUnder } from './capture.js';
|
|
4
4
|
function diffProps(propsA, propsB, fallbackA, fallbackB, unsetA, unsetB) {
|
|
5
5
|
const changed = [];
|
|
6
6
|
for (const prop of new Set([...Object.keys(propsA), ...Object.keys(propsB)])) {
|
|
@@ -16,7 +16,14 @@ function diffProps(propsA, propsB, fallbackA, fallbackB, unsetA, unsetB) {
|
|
|
16
16
|
/** Diff two style maps of the same surface. */
|
|
17
17
|
export function diffStyleMaps(a, b) {
|
|
18
18
|
const findings = [];
|
|
19
|
+
// Live regions either capture flagged as nondeterministic (a stream, ticker,
|
|
20
|
+
// late-loading content): never diff them — their values move with no code
|
|
21
|
+
// change. Union both sides so a region volatile on only one capture is still
|
|
22
|
+
// skipped, in every layer (element, pseudo, forced-state).
|
|
23
|
+
const volatile = [...new Set([...(a.volatile ?? []), ...(b.volatile ?? [])])];
|
|
19
24
|
for (const p of [...new Set([...Object.keys(a.elements), ...Object.keys(b.elements)])].sort()) {
|
|
25
|
+
if (volatile.length && isUnder(p, volatile))
|
|
26
|
+
continue;
|
|
20
27
|
const ea = a.elements[p];
|
|
21
28
|
const eb = b.elements[p];
|
|
22
29
|
if (!ea || !eb) {
|
|
@@ -62,6 +69,8 @@ export function diffStyleMaps(a, b) {
|
|
|
62
69
|
});
|
|
63
70
|
}
|
|
64
71
|
for (const p of new Set([...Object.keys(a.states ?? {}), ...Object.keys(b.states ?? {})])) {
|
|
72
|
+
if (volatile.length && isUnder(p, volatile))
|
|
73
|
+
continue;
|
|
65
74
|
const sa = a.states?.[p] ?? {};
|
|
66
75
|
const sb = b.states?.[p] ?? {};
|
|
67
76
|
const cls = (a.elements[p] ?? b.elements[p])?.cls ?? '';
|
|
@@ -77,13 +86,25 @@ export function diffStyleMaps(a, b) {
|
|
|
77
86
|
}
|
|
78
87
|
return findings;
|
|
79
88
|
}
|
|
89
|
+
/** Add a surface's findings to the running totals (one DOM/style/state tally). */
|
|
90
|
+
function tallyCounts(findings, counts) {
|
|
91
|
+
for (const f of findings) {
|
|
92
|
+
if (f.kind === 'dom')
|
|
93
|
+
counts.dom++;
|
|
94
|
+
else if (f.kind === 'style')
|
|
95
|
+
counts.style += f.props.length;
|
|
96
|
+
else
|
|
97
|
+
counts.state += f.props.length;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
80
100
|
function indexDir(dir) {
|
|
81
101
|
return Object.fromEntries(fs
|
|
82
102
|
.readdirSync(dir)
|
|
83
103
|
.filter((f) => /\.json(\.gz)?$/.test(f))
|
|
84
104
|
.map((f) => [f.replace(/\.json(\.gz)?$/, ''), path.join(dir, f)]));
|
|
85
105
|
}
|
|
86
|
-
/** Diff every same-named capture between two directories.
|
|
106
|
+
/** Diff every same-named capture between two directories. `volatile` is the
|
|
107
|
+
* count of live regions auto-excluded across all surfaces (union per surface). */
|
|
87
108
|
export function diffStyleMapDirs(dirA, dirB) {
|
|
88
109
|
const indexA = indexDir(dirA);
|
|
89
110
|
const indexB = indexDir(dirB);
|
|
@@ -92,25 +113,25 @@ export function diffStyleMapDirs(dirA, dirB) {
|
|
|
92
113
|
throw new Error(`no .json(.gz) captures found in ${dirA} or ${dirB}`);
|
|
93
114
|
const surfaces = [];
|
|
94
115
|
const counts = { dom: 0, style: 0, state: 0 };
|
|
116
|
+
let volatile = 0;
|
|
95
117
|
for (const surface of names) {
|
|
96
118
|
if (!indexA[surface] || !indexB[surface]) {
|
|
119
|
+
// A surface present on only one side has no baseline to diff against — it's
|
|
120
|
+
// a NEW surface, not a style change. It does NOT count toward the change
|
|
121
|
+
// tallies (those drive the review gate); the consumer flags it separately
|
|
122
|
+
// off the `missing` marker and shows it for reference without blocking.
|
|
97
123
|
surfaces.push({ surface, missing: indexA[surface] ? 'after' : 'before', findings: [] });
|
|
98
|
-
counts.dom++;
|
|
99
124
|
continue;
|
|
100
125
|
}
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
counts.style += f.props.length;
|
|
107
|
-
else
|
|
108
|
-
counts.state += f.props.length;
|
|
109
|
-
}
|
|
126
|
+
const mapA = loadStyleMap(indexA[surface]);
|
|
127
|
+
const mapB = loadStyleMap(indexB[surface]);
|
|
128
|
+
volatile += new Set([...(mapA.volatile ?? []), ...(mapB.volatile ?? [])]).size;
|
|
129
|
+
const findings = diffStyleMaps(mapA, mapB);
|
|
130
|
+
tallyCounts(findings, counts);
|
|
110
131
|
if (findings.length)
|
|
111
132
|
surfaces.push({ surface, findings });
|
|
112
133
|
}
|
|
113
|
-
return { surfaces, counts };
|
|
134
|
+
return { surfaces, counts, volatile };
|
|
114
135
|
}
|
|
115
136
|
/** Human label: structural path plus a truncated class hint. */
|
|
116
137
|
export function findingLabel(path, cls) {
|
package/dist/report.d.ts
CHANGED
|
@@ -41,7 +41,10 @@ export type ReportOptions = {
|
|
|
41
41
|
includeLayoutNoise?: boolean;
|
|
42
42
|
};
|
|
43
43
|
export type ReportResult = {
|
|
44
|
+
/** Surfaces carrying a reviewable change (excludes new, one-sided surfaces). */
|
|
44
45
|
changedSurfaces: number;
|
|
46
|
+
/** New surfaces present on only one side, with no baseline to compare. */
|
|
47
|
+
newSurfaces: number;
|
|
45
48
|
totalFindings: number;
|
|
46
49
|
reportMdPath: string;
|
|
47
50
|
reportJsonPath: string;
|
package/dist/report.js
CHANGED
|
@@ -3,6 +3,10 @@ import path from 'node:path';
|
|
|
3
3
|
import { PNG } from 'pngjs';
|
|
4
4
|
import { loadStyleMap } from './capture.js';
|
|
5
5
|
import { diffStyleMapDirs } from './diff.js';
|
|
6
|
+
// Hidden marker appended to a new-surface heading. Invisible in rendered
|
|
7
|
+
// markdown; lets the PR-comment layer attach an OPTIONAL "approve" box to a new
|
|
8
|
+
// surface (vs the required box on a real change), so new surfaces never gate.
|
|
9
|
+
const NEW_SURFACE_MARKER = '<!-- styleproof-new -->';
|
|
6
10
|
const rectToBox = (r) => ({ x: r[0], y: r[1], w: r[2], h: r[3] });
|
|
7
11
|
const pad = (b, by) => ({ x: b.x - by, y: b.y - by, w: b.w + 2 * by, h: b.h + 2 * by });
|
|
8
12
|
const union = (a, b) => {
|
|
@@ -520,7 +524,7 @@ const stripDerived = (f) => f.kind === 'dom' ? f : { ...f, props: f.props.filter
|
|
|
520
524
|
export function generateStyleMapReport(opts) {
|
|
521
525
|
const { beforeDir, afterDir, outDir, imageBaseUrl = '', pad: padBy = 24, minWidth = 320, minHeight = 180, maxHeight = 1600, maxCrops = 6, foldDetailsAt = 0, } = opts;
|
|
522
526
|
const includeNoise = opts.includeLayoutNoise ?? false;
|
|
523
|
-
const { surfaces } = diffStyleMapDirs(beforeDir, afterDir);
|
|
527
|
+
const { surfaces, volatile: volatileCount } = diffStyleMapDirs(beforeDir, afterDir);
|
|
524
528
|
fs.mkdirSync(path.join(outDir, 'crops'), { recursive: true });
|
|
525
529
|
// Focus each surface on styling intent: drop reflow-casualty elements (only
|
|
526
530
|
// size/position-derived changes) and strip those props from the rest, unless
|
|
@@ -563,7 +567,10 @@ export function generateStyleMapReport(opts) {
|
|
|
563
567
|
else
|
|
564
568
|
shown.state += summarizeProps(f.props).length;
|
|
565
569
|
}
|
|
566
|
-
|
|
570
|
+
// Surfaces carrying a reviewable change — NOT the new (one-sided) ones, which
|
|
571
|
+
// have no baseline to compare and are summarised on their own line below so the
|
|
572
|
+
// headline never reads "0 changes" while warnings sit beneath it.
|
|
573
|
+
const changedSurfaceCount = changeGroups.reduce((acc, g) => acc + g.surfaces.length, 0);
|
|
567
574
|
const md = [];
|
|
568
575
|
const json = [];
|
|
569
576
|
const img = (rel) => (imageBaseUrl ? `${imageBaseUrl.replace(/\/$/, '')}/${rel}` : rel);
|
|
@@ -572,8 +579,19 @@ export function generateStyleMapReport(opts) {
|
|
|
572
579
|
md.push('✓ All surfaces identical: every computed style, pseudo-element, and hover/focus/active state matches.');
|
|
573
580
|
}
|
|
574
581
|
else {
|
|
575
|
-
|
|
576
|
-
|
|
582
|
+
if (changeGroups.length > 0) {
|
|
583
|
+
md.push(`**${shown.dom} DOM change(s) · ${shown.style} computed-style difference(s) · ${shown.state} state-delta difference(s)** ` +
|
|
584
|
+
`across ${changeGroups.length} distinct change(s) in ${changedSurfaceCount} surface(s).`);
|
|
585
|
+
}
|
|
586
|
+
if (missing.length > 0) {
|
|
587
|
+
if (changeGroups.length > 0)
|
|
588
|
+
md.push('');
|
|
589
|
+
md.push(`🆕 **${missing.length} new surface(s)** captured with no baseline to compare — shown below for reference. ` +
|
|
590
|
+
`New surfaces don't block the check.`);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (volatileCount > 0) {
|
|
594
|
+
md.push('', `_${volatileCount} live region(s) auto-excluded as nondeterministic (a stream, ticker, or late-loading content) — they don't affect the check._`);
|
|
577
595
|
}
|
|
578
596
|
let totalFindings = 0;
|
|
579
597
|
let cropSeq = 0;
|
|
@@ -646,15 +664,41 @@ export function generateStyleMapReport(opts) {
|
|
|
646
664
|
surfaceJson.findings = surfaceFindings;
|
|
647
665
|
json.push(surfaceJson);
|
|
648
666
|
}
|
|
667
|
+
// New surfaces: present on only one side, so there's nothing to diff. Show the
|
|
668
|
+
// captured side as a single screenshot for reference and mark the heading so the
|
|
669
|
+
// PR comment can attach an OPTIONAL approval box — these never gate the check.
|
|
649
670
|
for (const p of missing) {
|
|
650
|
-
|
|
651
|
-
|
|
671
|
+
const side = p.sd.missing === 'before' ? 'after' : 'before';
|
|
672
|
+
const srcDir = side === 'after' ? afterDir : beforeDir;
|
|
673
|
+
const png = readPng(path.join(srcDir, `${p.sd.surface}.png`));
|
|
674
|
+
md.push('', `### \`${p.sd.surface}\` · new surface ${NEW_SURFACE_MARKER}`, '', `_${formatSurfaceList([p.sd.surface])}_`);
|
|
675
|
+
const surfaceJson = { surface: p.sd.surface, missing: p.sd.missing, isNew: true };
|
|
676
|
+
if (png) {
|
|
677
|
+
cropSeq++;
|
|
678
|
+
const h = Math.min(maxHeight, png.height);
|
|
679
|
+
const crop = cropPng(png, { x: 0, y: 0, w: png.width, h }, png.width, h);
|
|
680
|
+
const stem = `crops/${p.sd.surface.replace(/[^a-z0-9-]/gi, '-')}-${cropSeq}-new`;
|
|
681
|
+
writePng(path.join(outDir, `${stem}.png`), crop);
|
|
682
|
+
md.push('', `})`, '', `<sub>${side} · ${p.sd.surface}${png.height > h ? ' (top of page)' : ''}</sub>`);
|
|
683
|
+
surfaceJson.image = `${stem}.png`;
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
md.push('', `_Captured only in the **${side}** set; no screenshot saved (run captures with \`screenshots: true\`)._`);
|
|
687
|
+
}
|
|
688
|
+
md.push('', `_No baseline to compare against — this surface is new, so it doesn't block the check. It becomes part of the baseline once this merges._`);
|
|
689
|
+
json.push(surfaceJson);
|
|
652
690
|
}
|
|
653
691
|
const reportMdPath = path.join(outDir, 'report.md');
|
|
654
692
|
const reportJsonPath = path.join(outDir, 'report.json');
|
|
655
693
|
fs.writeFileSync(reportMdPath, md.join('\n') + '\n');
|
|
656
694
|
fs.writeFileSync(reportJsonPath, JSON.stringify({ counts: shown, surfaces: json }, null, 2));
|
|
657
|
-
return {
|
|
695
|
+
return {
|
|
696
|
+
changedSurfaces: prepared.length - missing.length,
|
|
697
|
+
newSurfaces: missing.length,
|
|
698
|
+
totalFindings,
|
|
699
|
+
reportMdPath,
|
|
700
|
+
reportJsonPath,
|
|
701
|
+
};
|
|
658
702
|
}
|
|
659
703
|
function findCapture(dir, surface) {
|
|
660
704
|
for (const ext of ['.json.gz', '.json']) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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",
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"test:watch": "node --test --watch test/*.test.mjs",
|
|
64
64
|
"test:e2e": "npm run build && playwright test test/smoke.e2e.spec.ts",
|
|
65
65
|
"init": "node ./bin/styleproof-init.mjs",
|
|
66
|
-
"prepare": "npm run build",
|
|
66
|
+
"prepare": "npm run build && husky",
|
|
67
67
|
"prepublishOnly": "npm run clean && npm run build && npm run typecheck"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|
|
@@ -73,14 +73,16 @@
|
|
|
73
73
|
"pngjs": "^7.0.0"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
-
"@eslint/js": "^
|
|
76
|
+
"@eslint/js": "^10.0.1",
|
|
77
77
|
"@playwright/test": "^1.60.0",
|
|
78
78
|
"@types/node": "^25.9.3",
|
|
79
79
|
"@types/pngjs": "^6.0.5",
|
|
80
|
-
"eslint": "^
|
|
81
|
-
"globals": "^
|
|
80
|
+
"eslint": "^10.5.0",
|
|
81
|
+
"globals": "^17.6.0",
|
|
82
82
|
"prettier": "^3.4.2",
|
|
83
|
-
"typescript": "^
|
|
84
|
-
"typescript-eslint": "^8.18.0"
|
|
83
|
+
"typescript": "^6.0.3",
|
|
84
|
+
"typescript-eslint": "^8.18.0",
|
|
85
|
+
"husky": "^9.1.7",
|
|
86
|
+
"fallow": "^2.96.0"
|
|
85
87
|
}
|
|
86
88
|
}
|