styleproof 2.3.0 → 2.4.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 +52 -0
- package/README.md +13 -13
- package/bin/styleproof-diff.mjs +90 -11
- package/bin/styleproof-init.mjs +101 -14
- package/dist/breakpoints.d.ts +30 -0
- package/dist/breakpoints.js +114 -0
- package/dist/capture.js +15 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/runner.d.ts +21 -2
- package/dist/runner.js +38 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,58 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.4.0] - 2026-06-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Automatic breakpoint detection — `Surface.widths` is now optional.** Omit it and
|
|
15
|
+
StyleProof reads the app's real viewport breakpoints from the loaded CSSOM at
|
|
16
|
+
capture time and sweeps one width per `@media` band — no config, no guessing.
|
|
17
|
+
Because it reads the browser's parsed stylesheets (not your source), it's
|
|
18
|
+
framework-agnostic: Tailwind, CSS Modules, styled-components, Sass and vanilla all
|
|
19
|
+
resolve to the same `@media` rules. It is authoritative **or it fails**: an
|
|
20
|
+
unreadable cross-origin stylesheet throws (so a band is never silently missed)
|
|
21
|
+
rather than guessing. `min/max-width` and range syntax (`width >= …`) are handled,
|
|
22
|
+
`em`/`rem` resolved against the root font size; container/print/height queries are
|
|
23
|
+
correctly ignored. Set `widths` explicitly to pin the sweep or to cover a JS-only
|
|
24
|
+
(`matchMedia`) breakpoint that has no CSS rule. New exports: `detectViewportWidths`,
|
|
25
|
+
`mediaTextWidthBoundaries`, `widthsFromBoundaries`. **`styleproof-init` now scaffolds
|
|
26
|
+
surfaces with no `widths`**, so a fresh project gets zero-config breakpoint detection
|
|
27
|
+
by default.
|
|
28
|
+
- **Out-of-the-box committed-map gate — capture pre-push, CI just diffs.**
|
|
29
|
+
`styleproof-init` now scaffolds the whole flow as the default: a **pre-push hook**
|
|
30
|
+
(`.githooks/pre-push`) captures this branch's computed-style map against a
|
|
31
|
+
production build, commits it as a lean `.json.gz` under `stylemaps/`, and **pushes
|
|
32
|
+
it with your branch — one `git push`, never two** (the hook pushes the map commit
|
|
33
|
+
itself, because git would otherwise send only the pre-hook commit); and a
|
|
34
|
+
**browser-less CI workflow** that just diffs the committed map against the base. So
|
|
35
|
+
`main` always carries a base map and every PR is a fast comparison of precomputed
|
|
36
|
+
maps, never a recapture. (Capture belongs pre-push because it needs the app built +
|
|
37
|
+
served; the same-environment requirement is self-enforced by the self-check.)
|
|
38
|
+
- **`styleproof-diff --base-ref <gitref>` — diff against a committed base in git.**
|
|
39
|
+
`styleproof-diff --base-ref main <mapsDir>` materialises the captures committed at
|
|
40
|
+
`<mapsDir>` as of `main` and diffs them against your working `<mapsDir>` — the CI
|
|
41
|
+
half of the gate above. Reads the base purely through git (`ls-tree`/`show`, no
|
|
42
|
+
`tar`/deps; binary `.json.gz` preserved), into a temp dir cleaned up after.
|
|
43
|
+
- **`STYLEPROOF_BASEDIR` / `STYLEPROOF_SCREENSHOTS` env knobs** (same wiring style as
|
|
44
|
+
`STYLEPROOF_REPLAY_*`): redirect capture into a committed dir and drop screenshots
|
|
45
|
+
for lean, commit-friendly maps — without editing the spec. This is what lets the
|
|
46
|
+
pre-push hook capture committed maps from the stock generated spec.
|
|
47
|
+
|
|
48
|
+
## [2.3.1] - 2026-06-23
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- **Animation freeze is now deterministic for content that mounts during the
|
|
53
|
+
settle.** Motion longhands (declared `animation`/`transition`) were read _before_
|
|
54
|
+
the settle, so an element that mounts while the page settles — a status glyph
|
|
55
|
+
gated on a snapshot fetch — missed that read: its declared `animation-duration`
|
|
56
|
+
was folded back on a run where it mounted early but left frozen to `0s` on a run
|
|
57
|
+
where it mounted late, surfacing as a self-check `animation-duration: 0s ↔ 1.6s`
|
|
58
|
+
non-deterministic failure. Motion is now read on the settled DOM (the freeze is
|
|
59
|
+
lifted only for that read), so a late-mounted animated element is captured
|
|
60
|
+
identically every run.
|
|
61
|
+
|
|
10
62
|
## [2.3.0] - 2026-06-23
|
|
11
63
|
|
|
12
64
|
### Added
|
package/README.md
CHANGED
|
@@ -292,19 +292,19 @@ It's **asynchronous by design**: approval is a checkbox tick handled by a separa
|
|
|
292
292
|
|
|
293
293
|
**Capture spec `defineStyleMapCapture({ surfaces, … })`** — determinism is on by default; you rarely set more than `surfaces` and `dir`:
|
|
294
294
|
|
|
295
|
-
| Option | Default | Purpose
|
|
296
|
-
| ------------- | --------------------------- |
|
|
297
|
-
| `surfaces` | _required_ | Page states to certify — each `{ key, go, widths
|
|
298
|
-
| `expected` | _none_ | Your route/view universe. Emits a coverage-guard test (runs without a capture dir) that fails when a route has no surface and isn't excluded — so a new page can't ship uncaptured.
|
|
299
|
-
| `exclude` | `{}` | `key → reason` for routes deliberately not captured. Keeps the guard green for known gaps; a key absent from `expected` fails the guard, so the ledger can't go stale.
|
|
300
|
-
| `dir` | `STYLEMAP_DIR` | Output label (`base`/`head`); the spec is **inert until set**, so it sits safely beside your other specs.
|
|
301
|
-
| `replayFrom` | `STYLEPROOF_REPLAY_FROM` | Baseline dir whose recorded responses to replay. Unset → this run **records** its HAR for the comparison to use.
|
|
302
|
-
| `replayUrl` | `**/api/**` (`…REPLAY_URL`) | URL glob for the data boundary to record/replay; everything else (JS/CSS/fonts) loads live so the code runs.
|
|
303
|
-
| `freezeClock` | `true` | Pin `Date.now()`/`new Date()` so time-derived styling can't drift; timers keep running so settling still works.
|
|
304
|
-
| `clockTime` | `2025-01-01T00:00:00Z` | The frozen instant.
|
|
305
|
-
| `selfCheck` | on while recording | Capture each surface twice and fail on any difference — proves the capture is deterministic. Off on the replay run; `STYLEPROOF_SELFCHECK=1` forces both.
|
|
306
|
-
| `screenshots` | `true` | Save full-page screenshots for the report's before/after crops.
|
|
307
|
-
| `baseDir` | `__stylemaps__` | Output root directory.
|
|
295
|
+
| Option | Default | Purpose |
|
|
296
|
+
| ------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
297
|
+
| `surfaces` | _required_ | Page states to certify — each `{ key, go, widths?, ignore?, height? }`. `go(page)` drives to a settled state. Omit `widths` to auto-detect the app's `@media` breakpoints and sweep one width per band. |
|
|
298
|
+
| `expected` | _none_ | Your route/view universe. Emits a coverage-guard test (runs without a capture dir) that fails when a route has no surface and isn't excluded — so a new page can't ship uncaptured. |
|
|
299
|
+
| `exclude` | `{}` | `key → reason` for routes deliberately not captured. Keeps the guard green for known gaps; a key absent from `expected` fails the guard, so the ledger can't go stale. |
|
|
300
|
+
| `dir` | `STYLEMAP_DIR` | Output label (`base`/`head`); the spec is **inert until set**, so it sits safely beside your other specs. |
|
|
301
|
+
| `replayFrom` | `STYLEPROOF_REPLAY_FROM` | Baseline dir whose recorded responses to replay. Unset → this run **records** its HAR for the comparison to use. |
|
|
302
|
+
| `replayUrl` | `**/api/**` (`…REPLAY_URL`) | URL glob for the data boundary to record/replay; everything else (JS/CSS/fonts) loads live so the code runs. |
|
|
303
|
+
| `freezeClock` | `true` | Pin `Date.now()`/`new Date()` so time-derived styling can't drift; timers keep running so settling still works. |
|
|
304
|
+
| `clockTime` | `2025-01-01T00:00:00Z` | The frozen instant. |
|
|
305
|
+
| `selfCheck` | on while recording | Capture each surface twice and fail on any difference — proves the capture is deterministic. Off on the replay run; `STYLEPROOF_SELFCHECK=1` forces both. |
|
|
306
|
+
| `screenshots` | `true` | Save full-page screenshots for the report's before/after crops. |
|
|
307
|
+
| `baseDir` | `__stylemaps__` | Output root directory. |
|
|
308
308
|
|
|
309
309
|
Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<style>`/… and `next-route-announcer`) are skipped automatically; a surface's `ignore` adds to that default, it doesn't replace it.
|
|
310
310
|
|
package/bin/styleproof-diff.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Diff two computed-style map captures (see styleproof).
|
|
4
4
|
*
|
|
5
5
|
* styleproof-diff <beforeDir> <afterDir> [--max N] [--json <file>]
|
|
6
|
+
* styleproof-diff --base-ref <gitref> <mapsDir> [--max N] [--json <file>]
|
|
6
7
|
*
|
|
7
8
|
* Reports, per surface:
|
|
8
9
|
* - DOM changes (elements added/removed/retagged) — a CSS-only refactor
|
|
@@ -13,18 +14,31 @@
|
|
|
13
14
|
* longer does (or now changes differently) — the classic dropped
|
|
14
15
|
* `hover:` variant a screenshot can never catch.
|
|
15
16
|
*
|
|
17
|
+
* --base-ref reads the base from a git ref instead of a second directory: it
|
|
18
|
+
* materialises the captures committed at <mapsDir> as of <gitref> (e.g. `main`)
|
|
19
|
+
* and diffs them against your working <mapsDir>. That's the "base map lives on
|
|
20
|
+
* main, CI just diffs" flow — commit each branch's maps (pre-push), and the gate
|
|
21
|
+
* never recomputes the base. Both sides must be captured in the same environment
|
|
22
|
+
* (browser + fonts) for the diff to be meaningful.
|
|
23
|
+
*
|
|
16
24
|
* Custom properties (--*) are ignored: they are inputs, not outcomes (see
|
|
17
25
|
* README). Exit code 0 = identical, 1 = reviewable differences, 2 = usage/capture
|
|
18
26
|
* error, 3 = only new surfaces (present on one side, no baseline to diff against).
|
|
19
27
|
*/
|
|
20
28
|
import fs from 'node:fs';
|
|
29
|
+
import os from 'node:os';
|
|
30
|
+
import path from 'node:path';
|
|
31
|
+
import { spawnSync } from 'node:child_process';
|
|
21
32
|
import { diffStyleMapDirs, findingLabel } from '../dist/diff.js';
|
|
22
33
|
|
|
23
34
|
const HELP = `styleproof-diff — certify a CSS refactor by diffing two computed-style captures
|
|
24
35
|
|
|
25
36
|
usage: styleproof-diff <beforeDir> <afterDir> [options]
|
|
37
|
+
styleproof-diff --base-ref <gitref> <mapsDir> [options]
|
|
26
38
|
|
|
27
39
|
options:
|
|
40
|
+
--base-ref <ref> diff <mapsDir> as committed at <ref> (e.g. main) against your
|
|
41
|
+
working <mapsDir> — base from git, no recapture
|
|
28
42
|
--max <n> max lines printed per surface before truncating (default: 40)
|
|
29
43
|
--json <file> also write the full structured diff to <file>
|
|
30
44
|
-h, --help show this help
|
|
@@ -37,6 +51,7 @@ const argv = process.argv.slice(2);
|
|
|
37
51
|
const args = [];
|
|
38
52
|
let MAX = 40;
|
|
39
53
|
let jsonOut = null;
|
|
54
|
+
let baseRef = null;
|
|
40
55
|
for (let i = 0; i < argv.length; i++) {
|
|
41
56
|
if (argv[i] === '-h' || argv[i] === '--help') {
|
|
42
57
|
process.stdout.write(HELP);
|
|
@@ -45,21 +60,85 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
45
60
|
else if (argv[i].startsWith('--max=')) MAX = Number(argv[i].slice(6));
|
|
46
61
|
else if (argv[i] === '--json') jsonOut = argv[++i];
|
|
47
62
|
else if (argv[i].startsWith('--json=')) jsonOut = argv[i].slice(7);
|
|
63
|
+
else if (argv[i] === '--base-ref') baseRef = argv[++i];
|
|
64
|
+
else if (argv[i].startsWith('--base-ref=')) baseRef = argv[i].slice(11);
|
|
48
65
|
else if (argv[i].startsWith('--')) {
|
|
49
66
|
console.error(`unknown flag: ${argv[i]}`);
|
|
50
67
|
process.exit(2);
|
|
51
68
|
} else args.push(argv[i]);
|
|
52
69
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
70
|
+
|
|
71
|
+
/** Run git, exiting 2 with a clean message on failure. */
|
|
72
|
+
function git(gitArgs, { encoding = 'buffer' } = {}) {
|
|
73
|
+
const r = spawnSync('git', gitArgs, { encoding, maxBuffer: 1 << 28 });
|
|
74
|
+
return r;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Materialise the captures committed at <dir> as of <ref> into a fresh temp dir,
|
|
79
|
+
* so the base of the diff is e.g. main's map — no checkout, no recapture. Reads
|
|
80
|
+
* only via git (no `tar`/deps); binary `.json.gz` files come through `git show`.
|
|
81
|
+
*/
|
|
82
|
+
function materializeRef(ref, dir) {
|
|
83
|
+
const top = git(['rev-parse', '--show-toplevel'], { encoding: 'utf8' });
|
|
84
|
+
if (top.status !== 0) {
|
|
85
|
+
console.error('styleproof-diff --base-ref must run inside a git repository');
|
|
86
|
+
process.exit(2);
|
|
87
|
+
}
|
|
88
|
+
const toplevel = top.stdout.trim();
|
|
89
|
+
// -z: NUL-separated, unquoted paths (handles spaces/unicode).
|
|
90
|
+
const ls = git(['ls-tree', '-r', '-z', '--name-only', ref, '--', dir], { encoding: 'utf8' });
|
|
91
|
+
if (ls.status !== 0) {
|
|
92
|
+
console.error(`cannot read ${dir} at ${ref}: ${(ls.stderr || '').toString().trim()}`);
|
|
93
|
+
process.exit(2);
|
|
94
|
+
}
|
|
95
|
+
const files = ls.stdout.split('\0').filter(Boolean);
|
|
96
|
+
if (files.length === 0) {
|
|
97
|
+
console.error(`no committed captures at ${dir} in ${ref} — commit the maps so the base lives in git`);
|
|
98
|
+
process.exit(2);
|
|
99
|
+
}
|
|
100
|
+
const dirRepoRel = path.relative(toplevel, path.resolve(dir));
|
|
101
|
+
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'styleproof-baseref-'));
|
|
102
|
+
for (const f of files) {
|
|
103
|
+
const show = git(['show', `${ref}:${f}`]); // buffer — preserves .json.gz bytes
|
|
104
|
+
if (show.status !== 0) {
|
|
105
|
+
console.error(`cannot read ${f} at ${ref}`);
|
|
106
|
+
process.exit(2);
|
|
107
|
+
}
|
|
108
|
+
const out = path.join(dest, path.relative(dirRepoRel, f));
|
|
109
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
110
|
+
fs.writeFileSync(out, show.stdout);
|
|
111
|
+
}
|
|
112
|
+
return dest;
|
|
56
113
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
114
|
+
|
|
115
|
+
let dirA;
|
|
116
|
+
let dirB;
|
|
117
|
+
let tmpBase = null;
|
|
118
|
+
if (baseRef) {
|
|
119
|
+
if (args.length !== 1 || !Number.isFinite(MAX)) {
|
|
120
|
+
console.error('usage: styleproof-diff --base-ref <gitref> <mapsDir> [--max N] [--json <file>]');
|
|
61
121
|
process.exit(2);
|
|
62
122
|
}
|
|
123
|
+
if (!fs.existsSync(args[0])) {
|
|
124
|
+
console.error(`no capture at ${args[0]}`);
|
|
125
|
+
process.exit(2);
|
|
126
|
+
}
|
|
127
|
+
tmpBase = materializeRef(baseRef, args[0]);
|
|
128
|
+
dirA = tmpBase;
|
|
129
|
+
dirB = args[0];
|
|
130
|
+
} else {
|
|
131
|
+
if (args.length !== 2 || !Number.isFinite(MAX)) {
|
|
132
|
+
console.error('usage: styleproof-diff <beforeDir> <afterDir> [--max N] [--json <file>] (--help for all options)');
|
|
133
|
+
process.exit(2);
|
|
134
|
+
}
|
|
135
|
+
[dirA, dirB] = args;
|
|
136
|
+
for (const d of [dirA, dirB]) {
|
|
137
|
+
if (!fs.existsSync(d)) {
|
|
138
|
+
console.error(`no capture at ${d}`);
|
|
139
|
+
process.exit(2);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
63
142
|
}
|
|
64
143
|
|
|
65
144
|
let result;
|
|
@@ -68,6 +147,8 @@ try {
|
|
|
68
147
|
} catch (e) {
|
|
69
148
|
console.error(e.message);
|
|
70
149
|
process.exit(2);
|
|
150
|
+
} finally {
|
|
151
|
+
if (tmpBase) fs.rmSync(tmpBase, { recursive: true, force: true });
|
|
71
152
|
}
|
|
72
153
|
const { surfaces, counts } = result;
|
|
73
154
|
|
|
@@ -102,10 +183,8 @@ if (jsonOut) fs.writeFileSync(jsonOut, JSON.stringify({ counts, surfaces }, null
|
|
|
102
183
|
|
|
103
184
|
const total = counts.dom + counts.style + counts.state;
|
|
104
185
|
const newSurfaces = surfaces.filter((s) => s.missing).length;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
...fs.readdirSync(dirB).filter((f) => /\.json(\.gz)?$/.test(f)),
|
|
108
|
-
]).size;
|
|
186
|
+
// One SurfaceDiff per distinct surface across both sides (incl. missing-on-one-side).
|
|
187
|
+
const surfaceCount = surfaces.length;
|
|
109
188
|
const newNote = newSurfaces ? ` (+${newSurfaces} new surface(s) with no baseline)` : '';
|
|
110
189
|
console.log(
|
|
111
190
|
total === 0
|
package/bin/styleproof-init.mjs
CHANGED
|
@@ -105,9 +105,9 @@ async function settle(page: Page) {
|
|
|
105
105
|
const HEADER = `/**
|
|
106
106
|
* styleproof capture spec (generated by \`styleproof-init\`).
|
|
107
107
|
*
|
|
108
|
-
* Each surface is one deterministic page state
|
|
109
|
-
* @media
|
|
110
|
-
* PRODUCTION build — dev servers inject
|
|
108
|
+
* Each surface is one deterministic page state. Omit \`widths\` and StyleProof
|
|
109
|
+
* detects your @media breakpoints from the loaded CSS and sweeps one viewport per
|
|
110
|
+
* band — no config. Capture against a PRODUCTION build — dev servers inject styles.
|
|
111
111
|
*
|
|
112
112
|
* STYLEMAP_DIR=before npx playwright test styleproof # capture baseline
|
|
113
113
|
* ...refactor your CSS...
|
|
@@ -140,7 +140,9 @@ const SURFACES: Surface[] = ROUTES.filter((r) => !r.dynamic).map((r) => ({
|
|
|
140
140
|
await settle(page);
|
|
141
141
|
},
|
|
142
142
|
ignore: [], // e.g. ['.live-feed', '.ad-slot'] for nondeterministic regions
|
|
143
|
-
widths
|
|
143
|
+
// No widths → StyleProof detects your @media breakpoints from the loaded CSS and
|
|
144
|
+
// sweeps one viewport per band. Pass an explicit array (e.g. 1280, 768, 390) to pin them (or to
|
|
145
|
+
// cover a JS-only matchMedia breakpoint that has no CSS @media rule).
|
|
144
146
|
}));
|
|
145
147
|
|
|
146
148
|
defineStyleMapCapture({
|
|
@@ -173,7 +175,9 @@ const SURFACES: Surface[] = [
|
|
|
173
175
|
await settle(page);
|
|
174
176
|
},
|
|
175
177
|
ignore: [], // e.g. ['.live-feed', '.ad-slot'] for nondeterministic regions
|
|
176
|
-
widths
|
|
178
|
+
// No widths → StyleProof detects your @media breakpoints from the loaded CSS and
|
|
179
|
+
// sweeps one viewport per band. Pass an explicit array (e.g. 1280, 768, 390) to pin them (or to
|
|
180
|
+
// cover a JS-only matchMedia breakpoint that has no CSS @media rule).
|
|
177
181
|
},
|
|
178
182
|
// Add one surface per distinct page state: open menus, dialogs, selected
|
|
179
183
|
// tabs, form errors — anything whose styling you want certified.
|
|
@@ -221,6 +225,70 @@ export default defineConfig({
|
|
|
221
225
|
});
|
|
222
226
|
`;
|
|
223
227
|
|
|
228
|
+
// The out-of-the-box gate: capture happens PRE-PUSH on your machine (where the app
|
|
229
|
+
// already builds + serves) and the lean computed-style map is committed, so main
|
|
230
|
+
// always carries a base map and CI is a fast, browser-less diff of two precomputed
|
|
231
|
+
// maps. The hook redirects capture into a committed `stylemaps/` dir and drops
|
|
232
|
+
// screenshots via the STYLEPROOF_BASEDIR / STYLEPROOF_SCREENSHOTS env knobs.
|
|
233
|
+
const HOOK_PATH = '.githooks/pre-push';
|
|
234
|
+
const PREPUSH_HOOK = `#!/bin/sh
|
|
235
|
+
# StyleProof pre-push hook (generated by styleproof-init).
|
|
236
|
+
#
|
|
237
|
+
# Captures this branch's computed-style map, commits it, and pushes it WITH your
|
|
238
|
+
# push — one \`git push\`, never two. So main always carries a base map and CI just
|
|
239
|
+
# diffs two precomputed maps (no browser). Maps are lean .json.gz under stylemaps/.
|
|
240
|
+
# Activate once per clone: git config core.hooksPath .githooks
|
|
241
|
+
set -e
|
|
242
|
+
|
|
243
|
+
remote="$1"
|
|
244
|
+
|
|
245
|
+
# Re-entry guard: the map commit is pushed by this hook (below), which fires the
|
|
246
|
+
# hook a second time — skip the expensive capture on that pass.
|
|
247
|
+
[ "\${STYLEPROOF_SKIP_CAPTURE:-}" = "1" ] && exit 0
|
|
248
|
+
|
|
249
|
+
STYLEMAP_DIR=current STYLEPROOF_BASEDIR=stylemaps STYLEPROOF_SCREENSHOTS=0 \\
|
|
250
|
+
npx playwright test ${specPath}
|
|
251
|
+
|
|
252
|
+
git add stylemaps
|
|
253
|
+
git diff --cached --quiet -- stylemaps && exit 0 # map unchanged — let the push proceed
|
|
254
|
+
|
|
255
|
+
# Map changed: commit it and push the updated branch ourselves so you never push
|
|
256
|
+
# twice (git pushes the pre-hook commit, so a new commit here wouldn't otherwise go).
|
|
257
|
+
# The re-entry guard stops the inner push from recapturing; then abort this original,
|
|
258
|
+
# now-stale push so nothing is sent twice.
|
|
259
|
+
git commit -m "chore(styleproof): update computed-style map"
|
|
260
|
+
echo "StyleProof: style map changed — committed it and pushing with your branch…" >&2
|
|
261
|
+
STYLEPROOF_SKIP_CAPTURE=1 git push "$remote" HEAD
|
|
262
|
+
echo "StyleProof: ✓ pushed your branch with the updated map — nothing more to do." >&2
|
|
263
|
+
echo "StyleProof: (git's 'failed to push some refs' below is expected; the push above already succeeded.)" >&2
|
|
264
|
+
exit 1
|
|
265
|
+
`;
|
|
266
|
+
|
|
267
|
+
const CI_PATH = '.github/workflows/styleproof.yml';
|
|
268
|
+
const CI_WORKFLOW = `name: StyleProof
|
|
269
|
+
|
|
270
|
+
# Maps are captured + committed pre-push (see .githooks/pre-push), so CI runs no
|
|
271
|
+
# browser — it just diffs this branch's committed map against the base branch's.
|
|
272
|
+
on: pull_request
|
|
273
|
+
|
|
274
|
+
permissions:
|
|
275
|
+
contents: read
|
|
276
|
+
|
|
277
|
+
jobs:
|
|
278
|
+
styleproof:
|
|
279
|
+
runs-on: ubuntu-latest
|
|
280
|
+
steps:
|
|
281
|
+
- uses: actions/checkout@v4
|
|
282
|
+
with:
|
|
283
|
+
fetch-depth: 0 # need the base ref to read its committed map
|
|
284
|
+
- uses: actions/setup-node@v4
|
|
285
|
+
with:
|
|
286
|
+
node-version: '20'
|
|
287
|
+
- run: npm ci
|
|
288
|
+
- name: Diff this branch's committed map against the base
|
|
289
|
+
run: npx styleproof-diff --base-ref "origin/\${{ github.event.pull_request.base.ref }}" stylemaps/current
|
|
290
|
+
`;
|
|
291
|
+
|
|
224
292
|
function writeFileSafe(file, contents, { force: f } = {}) {
|
|
225
293
|
const exists = fs.existsSync(file);
|
|
226
294
|
if (exists && !f) return { wrote: false, exists: true };
|
|
@@ -263,15 +331,34 @@ if (fs.existsSync(configPath) || fs.existsSync('playwright.config.js')) {
|
|
|
263
331
|
wroteSomething = true;
|
|
264
332
|
}
|
|
265
333
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
console.log(
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
console.log(
|
|
274
|
-
|
|
334
|
+
// Pre-push capture+commit hook — the out-of-the-box gate (capture local, CI diffs).
|
|
335
|
+
const hook = writeFileSafe(HOOK_PATH, PREPUSH_HOOK, { force });
|
|
336
|
+
if (hook.wrote) {
|
|
337
|
+
fs.chmodSync(HOOK_PATH, 0o755);
|
|
338
|
+
console.log(`${hook.exists ? 'overwrote' : 'created'} ${HOOK_PATH} (pre-push capture + commit)`);
|
|
339
|
+
wroteSomething = true;
|
|
340
|
+
} else {
|
|
341
|
+
console.log(`${HOOK_PATH} already exists — left untouched (use --force to overwrite)`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Browser-less CI diff — never overwrite an existing workflow.
|
|
345
|
+
const ci = writeFileSafe(CI_PATH, CI_WORKFLOW);
|
|
346
|
+
if (ci.wrote) {
|
|
347
|
+
console.log(`created ${CI_PATH} (browser-less CI diff)`);
|
|
348
|
+
wroteSomething = true;
|
|
349
|
+
} else {
|
|
350
|
+
console.log(`${CI_PATH} already exists — left untouched`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
console.log('\nHow the gate works — capture local, CI just diffs:');
|
|
354
|
+
console.log(' 1. Activate the pre-push hook (once per clone):');
|
|
355
|
+
console.log(' git config core.hooksPath .githooks');
|
|
356
|
+
console.log(' 2. Push your branch. The hook captures the computed-style map against a');
|
|
357
|
+
console.log(' PRODUCTION build, commits it (lean .json.gz under stylemaps/), and pushes it');
|
|
358
|
+
console.log(' with your branch — one `git push`, never two.');
|
|
359
|
+
console.log(' 3. On the PR, CI diffs your committed map against the base branch — no browser,');
|
|
360
|
+
console.log(' just a fast comparison of precomputed maps. An empty diff certifies no change.');
|
|
361
|
+
console.log(' So main always carries a base map and every PR is a quick diff, not a recapture.');
|
|
275
362
|
|
|
276
363
|
if (!wroteSomething) console.log('\nnothing to write — project already scaffolded.');
|
|
277
364
|
process.exit(0);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Page } from '@playwright/test';
|
|
2
|
+
/**
|
|
3
|
+
* Parse the px width BOUNDARIES a single `@media` condition introduces — the widths
|
|
4
|
+
* at which its match flips. A `min-width: V` opens a band at `V`; a `max-width: V`
|
|
5
|
+
* keeps the band below active through `V`, so the next band opens at `V + 1`. Range
|
|
6
|
+
* syntax (`width >= V`, `V <= width`, `width < V`, …) is normalised the same way.
|
|
7
|
+
* `em`/`rem` are resolved against `rootFontPx`. Non-width conditions yield nothing.
|
|
8
|
+
*/
|
|
9
|
+
export declare function mediaTextWidthBoundaries(mediaText: string, rootFontPx?: number): number[];
|
|
10
|
+
/**
|
|
11
|
+
* Turn breakpoint boundaries into one representative viewport width per band: the
|
|
12
|
+
* base band below the first boundary uses `baseWidth` (clamped strictly inside it),
|
|
13
|
+
* every other band its lower boundary. With no boundaries (no width `@media` rules)
|
|
14
|
+
* the layout is band-invariant, so a single `noQueryWidth` covers it. Ascending,
|
|
15
|
+
* de-duplicated.
|
|
16
|
+
*/
|
|
17
|
+
export declare function widthsFromBoundaries(boundaries: number[], opts?: {
|
|
18
|
+
baseWidth?: number;
|
|
19
|
+
noQueryWidth?: number;
|
|
20
|
+
}): number[];
|
|
21
|
+
/**
|
|
22
|
+
* Detect the viewport widths to sweep for the currently-loaded page: read every
|
|
23
|
+
* `@media` width breakpoint from the live CSSOM and return one width per band.
|
|
24
|
+
* Throws if any stylesheet is unreadable (cross-origin) — detection is authoritative
|
|
25
|
+
* or it fails; it never guesses. Call after the page has loaded its styles.
|
|
26
|
+
*/
|
|
27
|
+
export declare function detectViewportWidths(page: Page, opts?: {
|
|
28
|
+
baseWidth?: number;
|
|
29
|
+
noQueryWidth?: number;
|
|
30
|
+
}): Promise<number[]>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/** Serialized into the browser by page.evaluate; cannot call module helpers. */
|
|
2
|
+
function collectMediaTexts() {
|
|
3
|
+
const mediaTexts = [];
|
|
4
|
+
const unreadable = [];
|
|
5
|
+
const walk = (rules) => {
|
|
6
|
+
for (const rule of Array.from(rules)) {
|
|
7
|
+
// CSSMediaRule has `.media`; a CSSContainerRule does not, so container
|
|
8
|
+
// queries (container-relative, not viewport) are correctly skipped here.
|
|
9
|
+
const media = rule.media;
|
|
10
|
+
if (media && typeof media.mediaText === 'string')
|
|
11
|
+
mediaTexts.push(media.mediaText);
|
|
12
|
+
// Descend into @supports / @layer / nested @media groups.
|
|
13
|
+
const nested = rule.cssRules;
|
|
14
|
+
if (nested) {
|
|
15
|
+
try {
|
|
16
|
+
walk(nested);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
/* a nested rule list we can't read — ignore, top-level catch reports sheets */
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
25
|
+
try {
|
|
26
|
+
const rules = sheet.cssRules; // throws for a cross-origin sheet with no CORS
|
|
27
|
+
if (rules)
|
|
28
|
+
walk(rules);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
unreadable.push(sheet.href ?? '<inline>');
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const rootFontPx = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
35
|
+
return { mediaTexts, unreadable, rootFontPx };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Parse the px width BOUNDARIES a single `@media` condition introduces — the widths
|
|
39
|
+
* at which its match flips. A `min-width: V` opens a band at `V`; a `max-width: V`
|
|
40
|
+
* keeps the band below active through `V`, so the next band opens at `V + 1`. Range
|
|
41
|
+
* syntax (`width >= V`, `V <= width`, `width < V`, …) is normalised the same way.
|
|
42
|
+
* `em`/`rem` are resolved against `rootFontPx`. Non-width conditions yield nothing.
|
|
43
|
+
*/
|
|
44
|
+
export function mediaTextWidthBoundaries(mediaText, rootFontPx = 16) {
|
|
45
|
+
const t = mediaText.toLowerCase();
|
|
46
|
+
const out = new Set();
|
|
47
|
+
const px = (v, unit) => (unit === 'px' ? v : v * rootFontPx);
|
|
48
|
+
const add = (v) => {
|
|
49
|
+
const n = Math.round(v);
|
|
50
|
+
if (n > 0)
|
|
51
|
+
out.add(n);
|
|
52
|
+
};
|
|
53
|
+
let m;
|
|
54
|
+
const reMinMax = /\((min|max)-width\s*:\s*([\d.]+)(px|r?em)\)/g;
|
|
55
|
+
while ((m = reMinMax.exec(t)) !== null) {
|
|
56
|
+
const v = px(parseFloat(m[2]), m[3]);
|
|
57
|
+
if (m[1] === 'min')
|
|
58
|
+
add(v);
|
|
59
|
+
else
|
|
60
|
+
add(v + 1); // max-width: V → next band opens at V+1
|
|
61
|
+
}
|
|
62
|
+
// Range syntax: `width <op> Vpx` and the mirrored `Vpx <op> width`.
|
|
63
|
+
const flip = (op) => (op === '<=' ? '>=' : op === '<' ? '>' : op === '>=' ? '<=' : '<');
|
|
64
|
+
const addCmp = (v, op) => {
|
|
65
|
+
if (op === '>=')
|
|
66
|
+
add(v);
|
|
67
|
+
else if (op === '>')
|
|
68
|
+
add(v + 1);
|
|
69
|
+
else if (op === '<=')
|
|
70
|
+
add(v + 1);
|
|
71
|
+
else if (op === '<')
|
|
72
|
+
add(v);
|
|
73
|
+
};
|
|
74
|
+
const reRight = /width\s*(<=|>=|<|>)\s*([\d.]+)(px|r?em)/g;
|
|
75
|
+
while ((m = reRight.exec(t)) !== null)
|
|
76
|
+
addCmp(px(parseFloat(m[2]), m[3]), m[1]);
|
|
77
|
+
const reLeft = /([\d.]+)(px|r?em)\s*(<=|>=|<|>)\s*width/g;
|
|
78
|
+
while ((m = reLeft.exec(t)) !== null)
|
|
79
|
+
addCmp(px(parseFloat(m[1]), m[2]), flip(m[3]));
|
|
80
|
+
return [...out].sort((a, b) => a - b);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Turn breakpoint boundaries into one representative viewport width per band: the
|
|
84
|
+
* base band below the first boundary uses `baseWidth` (clamped strictly inside it),
|
|
85
|
+
* every other band its lower boundary. With no boundaries (no width `@media` rules)
|
|
86
|
+
* the layout is band-invariant, so a single `noQueryWidth` covers it. Ascending,
|
|
87
|
+
* de-duplicated.
|
|
88
|
+
*/
|
|
89
|
+
export function widthsFromBoundaries(boundaries, opts = {}) {
|
|
90
|
+
const baseWidth = opts.baseWidth ?? 360;
|
|
91
|
+
const noQueryWidth = opts.noQueryWidth ?? 1280;
|
|
92
|
+
const bps = [...new Set(boundaries.map((n) => Math.round(n)))].filter((n) => n > 0).sort((a, b) => a - b);
|
|
93
|
+
if (bps.length === 0)
|
|
94
|
+
return [noQueryWidth];
|
|
95
|
+
const base = Math.min(baseWidth, bps[0] - 1);
|
|
96
|
+
return [...new Set([base, ...bps])].filter((n) => n > 0).sort((a, b) => a - b);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Detect the viewport widths to sweep for the currently-loaded page: read every
|
|
100
|
+
* `@media` width breakpoint from the live CSSOM and return one width per band.
|
|
101
|
+
* Throws if any stylesheet is unreadable (cross-origin) — detection is authoritative
|
|
102
|
+
* or it fails; it never guesses. Call after the page has loaded its styles.
|
|
103
|
+
*/
|
|
104
|
+
export async function detectViewportWidths(page, opts = {}) {
|
|
105
|
+
const { mediaTexts, unreadable, rootFontPx } = await page.evaluate(collectMediaTexts);
|
|
106
|
+
if (unreadable.length > 0) {
|
|
107
|
+
throw new Error(`styleproof: can't detect breakpoints — ${unreadable.length} stylesheet(s) are unreadable ` +
|
|
108
|
+
`(cross-origin, no CORS): ${unreadable.join(', ')}. Detection reads every stylesheet to stay ` +
|
|
109
|
+
`100% accurate, so it fails rather than guess. Make them same-origin / CORS-readable, or set ` +
|
|
110
|
+
`\`widths\` on the surface to skip detection.`);
|
|
111
|
+
}
|
|
112
|
+
const boundaries = mediaTexts.flatMap((t) => mediaTextWidthBoundaries(t, rootFontPx));
|
|
113
|
+
return widthsFromBoundaries(boundaries, opts);
|
|
114
|
+
}
|
package/dist/capture.js
CHANGED
|
@@ -562,15 +562,26 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
562
562
|
// can't share a module-scope function) call ONE definition — no duplicated source.
|
|
563
563
|
// It persists on `window` for every evaluate below (no navigation between them).
|
|
564
564
|
await page.evaluate(injectPathOf);
|
|
565
|
-
//
|
|
566
|
-
//
|
|
567
|
-
|
|
568
|
-
await page.addStyleTag({ content: FREEZE_CSS });
|
|
565
|
+
// Freeze motion BEFORE settling: animating elements would otherwise read as
|
|
566
|
+
// perpetual churn during the settle, and any content that mounts during the
|
|
567
|
+
// settle must be frozen by the time we read it below.
|
|
568
|
+
const freezeTag = await page.addStyleTag({ content: FREEZE_CSS });
|
|
569
569
|
// Settle: wait for async content to finish painting so base and head capture
|
|
570
570
|
// the same loaded state, and collect any region still changing on its own
|
|
571
571
|
// (a live stream/ticker) to exclude — animations are frozen above, so only
|
|
572
572
|
// real content/layout churn lands here.
|
|
573
573
|
const volatile = await detectVolatile(page, ignore, stabilize, captureText, options.pendingRequests);
|
|
574
|
+
// Motion longhands (transition/animation) are read separately so declared
|
|
575
|
+
// motion is verified even though every other value is a frozen end state.
|
|
576
|
+
// Read them on the SETTLED DOM, not before it: capturing pre-settle missed any
|
|
577
|
+
// element that mounts DURING the settle (e.g. a late status glyph), so its
|
|
578
|
+
// declared `animation-duration` was folded back on the run where it happened to
|
|
579
|
+
// mount early but left frozen (0s) on the run where it mounted late — a
|
|
580
|
+
// self-check "non-deterministic" flip. Lift the freeze just for this read (it
|
|
581
|
+
// nulls motion to 0s), then re-apply it before reading everything else.
|
|
582
|
+
await freezeTag.evaluate((el) => el.remove());
|
|
583
|
+
const motion = await page.evaluate(capturePage, { ignore, motionOnly: true, captureText: false });
|
|
584
|
+
await page.addStyleTag({ content: FREEZE_CSS });
|
|
574
585
|
const base = await page.evaluate(capturePage, { ignore, motionOnly: false, captureText, captureComponent });
|
|
575
586
|
dropVolatile(base.elements, volatile);
|
|
576
587
|
warnUntraversed(base.shadowHosts, base.sameOriginFrames);
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
|
|
|
4
4
|
export type { Surface, DefineOptions, CrawlOptions } from './runner.js';
|
|
5
5
|
export { coverageGaps } from './coverage.js';
|
|
6
6
|
export type { CoverageGaps } from './coverage.js';
|
|
7
|
+
export { detectViewportWidths, mediaTextWidthBoundaries, widthsFromBoundaries } from './breakpoints.js';
|
|
7
8
|
export { discoverNextRoutes } from './routes.js';
|
|
8
9
|
export type { DiscoveredRoute } from './routes.js';
|
|
9
10
|
export { selectCrawlLinks, defaultLinkKey } from './crawl.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { captureStyleMap, saveStyleMap, loadStyleMap, trackInflightRequests } from './capture.js';
|
|
2
2
|
export { defineStyleMapCapture, defineCrawlCapture } from './runner.js';
|
|
3
3
|
export { coverageGaps } from './coverage.js';
|
|
4
|
+
export { detectViewportWidths, mediaTextWidthBoundaries, widthsFromBoundaries } from './breakpoints.js';
|
|
4
5
|
export { discoverNextRoutes } from './routes.js';
|
|
5
6
|
export { selectCrawlLinks, defaultLinkKey } from './crawl.js';
|
|
6
7
|
export { diffStyleMaps, diffStyleMapDirs, diffContentMaps, diffContentDirs, findingLabel } from './diff.js';
|
package/dist/runner.d.ts
CHANGED
|
@@ -16,8 +16,14 @@ export type Surface = {
|
|
|
16
16
|
go: (page: Page) => Promise<void>;
|
|
17
17
|
/** Selectors for nondeterministic regions (live data, third-party embeds); skipped entirely. */
|
|
18
18
|
ignore?: string[];
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Viewport widths to sweep — one per @media band, so breakpoint rules are verified
|
|
21
|
+
* too. OMIT to detect the app's real breakpoints from the loaded CSSOM and sweep one
|
|
22
|
+
* width per band automatically (no config); detection fails loudly if a stylesheet
|
|
23
|
+
* is cross-origin/unreadable rather than guess. Set it explicitly to pin the sweep
|
|
24
|
+
* or to cover a JS-only (`matchMedia`) breakpoint that has no CSS `@media` rule.
|
|
25
|
+
*/
|
|
26
|
+
widths?: number[];
|
|
21
27
|
/** Viewport height: a number, or a function of the width (default 800). */
|
|
22
28
|
height?: number | ((width: number) => number);
|
|
23
29
|
};
|
|
@@ -129,6 +135,19 @@ export declare function passLiveStreams(page: Page, url: string): Promise<void>;
|
|
|
129
135
|
* forces it on either way.
|
|
130
136
|
*/
|
|
131
137
|
export declare function defaultSelfCheck(replayFrom: string | undefined, env?: string | undefined): boolean;
|
|
138
|
+
/**
|
|
139
|
+
* Output base dir: explicit `baseDir` wins, then `STYLEPROOF_BASEDIR`, then the
|
|
140
|
+
* default. Lets a pre-push hook redirect capture into a COMMITTED dir (so main
|
|
141
|
+
* always carries a base map and CI just diffs precomputed maps) without editing
|
|
142
|
+
* the spec — same env-wiring philosophy as `STYLEPROOF_REPLAY_*`.
|
|
143
|
+
*/
|
|
144
|
+
export declare function resolveBaseDir(baseDir: string | undefined, env?: string | undefined): string;
|
|
145
|
+
/**
|
|
146
|
+
* Whether to save full-page screenshots: explicit `screenshots` wins, else
|
|
147
|
+
* `STYLEPROOF_SCREENSHOTS=0` turns them off — for committed maps you want the lean
|
|
148
|
+
* `.json.gz` only, never PNGs in git history. On by default otherwise.
|
|
149
|
+
*/
|
|
150
|
+
export declare function resolveScreenshots(screenshots: boolean | undefined, env?: string | undefined): boolean;
|
|
132
151
|
/** The capture settings every capturer shares (everything bar the surface set). */
|
|
133
152
|
type CaptureConfig = Omit<DefineOptions, 'surfaces' | 'expected' | 'exclude'>;
|
|
134
153
|
/**
|
package/dist/runner.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { captureStyleMap, saveStyleMap, trackInflightRequests } from './capture.js';
|
|
5
5
|
import { diffStyleMaps } from './diff.js';
|
|
6
6
|
import { coverageGaps } from './coverage.js';
|
|
7
|
+
import { detectViewportWidths } from './breakpoints.js';
|
|
7
8
|
import { selectCrawlLinks } from './crawl.js';
|
|
8
9
|
/** One-line description of the first drift finding, for the self-check error. */
|
|
9
10
|
function driftDesc(f) {
|
|
@@ -125,6 +126,23 @@ async function captureSurface(page, surface, width, s) {
|
|
|
125
126
|
export function defaultSelfCheck(replayFrom, env = process.env.STYLEPROOF_SELFCHECK) {
|
|
126
127
|
return env === '1' || !replayFrom;
|
|
127
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* Output base dir: explicit `baseDir` wins, then `STYLEPROOF_BASEDIR`, then the
|
|
131
|
+
* default. Lets a pre-push hook redirect capture into a COMMITTED dir (so main
|
|
132
|
+
* always carries a base map and CI just diffs precomputed maps) without editing
|
|
133
|
+
* the spec — same env-wiring philosophy as `STYLEPROOF_REPLAY_*`.
|
|
134
|
+
*/
|
|
135
|
+
export function resolveBaseDir(baseDir, env = process.env.STYLEPROOF_BASEDIR) {
|
|
136
|
+
return baseDir ?? env ?? '__stylemaps__';
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Whether to save full-page screenshots: explicit `screenshots` wins, else
|
|
140
|
+
* `STYLEPROOF_SCREENSHOTS=0` turns them off — for committed maps you want the lean
|
|
141
|
+
* `.json.gz` only, never PNGs in git history. On by default otherwise.
|
|
142
|
+
*/
|
|
143
|
+
export function resolveScreenshots(screenshots, env = process.env.STYLEPROOF_SCREENSHOTS) {
|
|
144
|
+
return screenshots ?? env !== '0';
|
|
145
|
+
}
|
|
128
146
|
/**
|
|
129
147
|
* Apply the capture defaults once, so explicit-surface and crawl capture can't
|
|
130
148
|
* drift — the replay boundary, frozen clock and self-check policy resolve to the
|
|
@@ -135,8 +153,8 @@ function resolveSettings(c) {
|
|
|
135
153
|
const replayFrom = c.replayFrom ?? process.env.STYLEPROOF_REPLAY_FROM;
|
|
136
154
|
return {
|
|
137
155
|
dir: c.dir,
|
|
138
|
-
baseDir: c.baseDir
|
|
139
|
-
screenshots: c.screenshots
|
|
156
|
+
baseDir: resolveBaseDir(c.baseDir),
|
|
157
|
+
screenshots: resolveScreenshots(c.screenshots),
|
|
140
158
|
replayFrom,
|
|
141
159
|
replayUrl: c.replayUrl ?? process.env.STYLEPROOF_REPLAY_URL ?? '**/api/**',
|
|
142
160
|
freezeClock: c.freezeClock ?? true,
|
|
@@ -182,10 +200,24 @@ export function defineStyleMapCapture(options) {
|
|
|
182
200
|
test.describe('styleproof capture', () => {
|
|
183
201
|
test.skip(!dir, 'set STYLEMAP_DIR=<label> to capture computed-style maps');
|
|
184
202
|
for (const surface of surfaces) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
203
|
+
if (surface.widths && surface.widths.length > 0) {
|
|
204
|
+
// Explicit widths: one parallelizable test per surface × width.
|
|
205
|
+
for (const width of surface.widths) {
|
|
206
|
+
test(`${surface.key} @ ${width}`, ({ page }) => {
|
|
207
|
+
test.setTimeout(180_000);
|
|
208
|
+
return captureSurface(page, surface, width, settings);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
// Auto widths: the band set isn't known until the page renders its CSS, so a
|
|
214
|
+
// single test loads once, detects the breakpoints, then sweeps each band.
|
|
215
|
+
test(`${surface.key} @ auto`, async ({ page }) => {
|
|
216
|
+
await surface.go(page);
|
|
217
|
+
const widths = await detectViewportWidths(page);
|
|
218
|
+
test.setTimeout(Math.max(180_000, widths.length * 60_000));
|
|
219
|
+
for (const width of widths)
|
|
220
|
+
await captureSurface(page, surface, width, settings);
|
|
189
221
|
});
|
|
190
222
|
}
|
|
191
223
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.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",
|