styleproof 2.3.1 → 2.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 +75 -0
- package/README.md +13 -13
- package/bin/styleproof-diff.mjs +50 -12
- package/bin/styleproof-init.mjs +109 -14
- package/bin/styleproof-report.mjs +35 -5
- package/dist/breakpoints.d.ts +30 -0
- package/dist/breakpoints.js +114 -0
- package/dist/gitref.d.ts +13 -0
- package/dist/gitref.js +44 -0
- 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 +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,81 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.5.0] - 2026-06-26
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **`styleproof-init` scaffolds parallel surface capture (`fullyParallel: true`).**
|
|
15
|
+
StyleProof emits one test per surface × width, each an isolated page writing a
|
|
16
|
+
uniquely-keyed file — independent and safe to run concurrently. Without
|
|
17
|
+
`fullyParallel`, all surfaces sit in one spec file and capture **serially**; with it
|
|
18
|
+
they fan out across workers. Measured: 6 surfaces went from 12.3 s (1 worker) to
|
|
19
|
+
4.9 s (4 workers) — **~2.5× faster**, byte-identical output. Profiling confirmed the
|
|
20
|
+
single-capture path is correctness-bound (the settle is a deterministic wait; forced
|
|
21
|
+
states must be isolated per element), so parallelism across surfaces — not
|
|
22
|
+
micro-optimising one capture — is the real lever.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **Dogfood: StyleProof certifies its own demo page in CI.** A new
|
|
27
|
+
`example/demo/index.html` plus `test/dogfood.e2e.spec.ts` run the full
|
|
28
|
+
capture → detect → diff pipeline on a real multi-element page every CI run:
|
|
29
|
+
auto-detection finds the demo's `@media` breakpoints, two captures certify
|
|
30
|
+
identical (determinism), and a planted restyle is caught. `test:e2e` now runs the
|
|
31
|
+
whole `*.e2e.spec.ts` suite. (Repo-only; `example/` isn't in the published package.)
|
|
32
|
+
|
|
33
|
+
- **`npm run bench` — measures the committed-map gate's CI speedup.** A reproducible
|
|
34
|
+
benchmark of one in-browser capture vs one precomputed-map diff, projected to a
|
|
35
|
+
sample app. On the dev fixture: capture ≈ 1 s/surface-width, diff ≈ 0.4 ms — so the
|
|
36
|
+
per-PR compare step is ~1000s× cheaper, before counting the build + serve the
|
|
37
|
+
committed-map CI also skips. (Internal tooling; not shipped in the package.)
|
|
38
|
+
- **The Action and `styleproof-report` support `--base-ref`.** The committed-map gate
|
|
39
|
+
now gets the full review experience, not just a bare diff: pass `base-ref` to the
|
|
40
|
+
Action (e.g. the PR base branch) and point `fresh-dir` at your committed maps — it
|
|
41
|
+
reads the base from git and produces the same before/after report + approval gate,
|
|
42
|
+
with no recapture. `styleproof-report --base-ref <gitref> <mapsDir>` does the same
|
|
43
|
+
on the CLI. The git-materialisation is now a shared `materializeRef` (in `gitref`)
|
|
44
|
+
used by both `styleproof-diff` and `styleproof-report`. `baseline-dir` is no longer
|
|
45
|
+
required when `base-ref` is set.
|
|
46
|
+
|
|
47
|
+
## [2.4.0] - 2026-06-26
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- **Automatic breakpoint detection — `Surface.widths` is now optional.** Omit it and
|
|
52
|
+
StyleProof reads the app's real viewport breakpoints from the loaded CSSOM at
|
|
53
|
+
capture time and sweeps one width per `@media` band — no config, no guessing.
|
|
54
|
+
Because it reads the browser's parsed stylesheets (not your source), it's
|
|
55
|
+
framework-agnostic: Tailwind, CSS Modules, styled-components, Sass and vanilla all
|
|
56
|
+
resolve to the same `@media` rules. It is authoritative **or it fails**: an
|
|
57
|
+
unreadable cross-origin stylesheet throws (so a band is never silently missed)
|
|
58
|
+
rather than guessing. `min/max-width` and range syntax (`width >= …`) are handled,
|
|
59
|
+
`em`/`rem` resolved against the root font size; container/print/height queries are
|
|
60
|
+
correctly ignored. Set `widths` explicitly to pin the sweep or to cover a JS-only
|
|
61
|
+
(`matchMedia`) breakpoint that has no CSS rule. New exports: `detectViewportWidths`,
|
|
62
|
+
`mediaTextWidthBoundaries`, `widthsFromBoundaries`. **`styleproof-init` now scaffolds
|
|
63
|
+
surfaces with no `widths`**, so a fresh project gets zero-config breakpoint detection
|
|
64
|
+
by default.
|
|
65
|
+
- **Out-of-the-box committed-map gate — capture pre-push, CI just diffs.**
|
|
66
|
+
`styleproof-init` now scaffolds the whole flow as the default: a **pre-push hook**
|
|
67
|
+
(`.githooks/pre-push`) captures this branch's computed-style map against a
|
|
68
|
+
production build, commits it as a lean `.json.gz` under `stylemaps/`, and **pushes
|
|
69
|
+
it with your branch — one `git push`, never two** (the hook pushes the map commit
|
|
70
|
+
itself, because git would otherwise send only the pre-hook commit); and a
|
|
71
|
+
**browser-less CI workflow** that just diffs the committed map against the base. So
|
|
72
|
+
`main` always carries a base map and every PR is a fast comparison of precomputed
|
|
73
|
+
maps, never a recapture. (Capture belongs pre-push because it needs the app built +
|
|
74
|
+
served; the same-environment requirement is self-enforced by the self-check.)
|
|
75
|
+
- **`styleproof-diff --base-ref <gitref>` — diff against a committed base in git.**
|
|
76
|
+
`styleproof-diff --base-ref main <mapsDir>` materialises the captures committed at
|
|
77
|
+
`<mapsDir>` as of `main` and diffs them against your working `<mapsDir>` — the CI
|
|
78
|
+
half of the gate above. Reads the base purely through git (`ls-tree`/`show`, no
|
|
79
|
+
`tar`/deps; binary `.json.gz` preserved), into a temp dir cleaned up after.
|
|
80
|
+
- **`STYLEPROOF_BASEDIR` / `STYLEPROOF_SCREENSHOTS` env knobs** (same wiring style as
|
|
81
|
+
`STYLEPROOF_REPLAY_*`): redirect capture into a committed dir and drop screenshots
|
|
82
|
+
for lean, commit-friendly maps — without editing the spec. This is what lets the
|
|
83
|
+
pre-push hook capture committed maps from the stock generated spec.
|
|
84
|
+
|
|
10
85
|
## [2.3.1] - 2026-06-23
|
|
11
86
|
|
|
12
87
|
### Fixed
|
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,29 @@
|
|
|
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';
|
|
21
29
|
import { diffStyleMapDirs, findingLabel } from '../dist/diff.js';
|
|
30
|
+
import { materializeRef, GitRefError } from '../dist/gitref.js';
|
|
22
31
|
|
|
23
32
|
const HELP = `styleproof-diff — certify a CSS refactor by diffing two computed-style captures
|
|
24
33
|
|
|
25
34
|
usage: styleproof-diff <beforeDir> <afterDir> [options]
|
|
35
|
+
styleproof-diff --base-ref <gitref> <mapsDir> [options]
|
|
26
36
|
|
|
27
37
|
options:
|
|
38
|
+
--base-ref <ref> diff <mapsDir> as committed at <ref> (e.g. main) against your
|
|
39
|
+
working <mapsDir> — base from git, no recapture
|
|
28
40
|
--max <n> max lines printed per surface before truncating (default: 40)
|
|
29
41
|
--json <file> also write the full structured diff to <file>
|
|
30
42
|
-h, --help show this help
|
|
@@ -37,6 +49,7 @@ const argv = process.argv.slice(2);
|
|
|
37
49
|
const args = [];
|
|
38
50
|
let MAX = 40;
|
|
39
51
|
let jsonOut = null;
|
|
52
|
+
let baseRef = null;
|
|
40
53
|
for (let i = 0; i < argv.length; i++) {
|
|
41
54
|
if (argv[i] === '-h' || argv[i] === '--help') {
|
|
42
55
|
process.stdout.write(HELP);
|
|
@@ -45,21 +58,46 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
45
58
|
else if (argv[i].startsWith('--max=')) MAX = Number(argv[i].slice(6));
|
|
46
59
|
else if (argv[i] === '--json') jsonOut = argv[++i];
|
|
47
60
|
else if (argv[i].startsWith('--json=')) jsonOut = argv[i].slice(7);
|
|
61
|
+
else if (argv[i] === '--base-ref') baseRef = argv[++i];
|
|
62
|
+
else if (argv[i].startsWith('--base-ref=')) baseRef = argv[i].slice(11);
|
|
48
63
|
else if (argv[i].startsWith('--')) {
|
|
49
64
|
console.error(`unknown flag: ${argv[i]}`);
|
|
50
65
|
process.exit(2);
|
|
51
66
|
} else args.push(argv[i]);
|
|
52
67
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
68
|
+
|
|
69
|
+
let dirA;
|
|
70
|
+
let dirB;
|
|
71
|
+
let tmpBase = null;
|
|
72
|
+
if (baseRef) {
|
|
73
|
+
if (args.length !== 1 || !Number.isFinite(MAX)) {
|
|
74
|
+
console.error('usage: styleproof-diff --base-ref <gitref> <mapsDir> [--max N] [--json <file>]');
|
|
75
|
+
process.exit(2);
|
|
76
|
+
}
|
|
77
|
+
if (!fs.existsSync(args[0])) {
|
|
78
|
+
console.error(`no capture at ${args[0]}`);
|
|
61
79
|
process.exit(2);
|
|
62
80
|
}
|
|
81
|
+
try {
|
|
82
|
+
tmpBase = materializeRef(baseRef, args[0]);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
console.error(e instanceof GitRefError ? `styleproof-diff --base-ref: ${e.message}` : String(e?.message ?? e));
|
|
85
|
+
process.exit(2);
|
|
86
|
+
}
|
|
87
|
+
dirA = tmpBase;
|
|
88
|
+
dirB = args[0];
|
|
89
|
+
} else {
|
|
90
|
+
if (args.length !== 2 || !Number.isFinite(MAX)) {
|
|
91
|
+
console.error('usage: styleproof-diff <beforeDir> <afterDir> [--max N] [--json <file>] (--help for all options)');
|
|
92
|
+
process.exit(2);
|
|
93
|
+
}
|
|
94
|
+
[dirA, dirB] = args;
|
|
95
|
+
for (const d of [dirA, dirB]) {
|
|
96
|
+
if (!fs.existsSync(d)) {
|
|
97
|
+
console.error(`no capture at ${d}`);
|
|
98
|
+
process.exit(2);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
63
101
|
}
|
|
64
102
|
|
|
65
103
|
let result;
|
|
@@ -68,6 +106,8 @@ try {
|
|
|
68
106
|
} catch (e) {
|
|
69
107
|
console.error(e.message);
|
|
70
108
|
process.exit(2);
|
|
109
|
+
} finally {
|
|
110
|
+
if (tmpBase) fs.rmSync(tmpBase, { recursive: true, force: true });
|
|
71
111
|
}
|
|
72
112
|
const { surfaces, counts } = result;
|
|
73
113
|
|
|
@@ -102,10 +142,8 @@ if (jsonOut) fs.writeFileSync(jsonOut, JSON.stringify({ counts, surfaces }, null
|
|
|
102
142
|
|
|
103
143
|
const total = counts.dom + counts.style + counts.state;
|
|
104
144
|
const newSurfaces = surfaces.filter((s) => s.missing).length;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
...fs.readdirSync(dirB).filter((f) => /\.json(\.gz)?$/.test(f)),
|
|
108
|
-
]).size;
|
|
145
|
+
// One SurfaceDiff per distinct surface across both sides (incl. missing-on-one-side).
|
|
146
|
+
const surfaceCount = surfaces.length;
|
|
109
147
|
const newNote = newSurfaces ? ` (+${newSurfaces} new surface(s) with no baseline)` : '';
|
|
110
148
|
console.log(
|
|
111
149
|
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.
|
|
@@ -205,6 +209,14 @@ const CONFIG = `import { defineConfig, devices } from '@playwright/test';
|
|
|
205
209
|
// in-flight data either way, but a production build removes the variance at the source.)
|
|
206
210
|
export default defineConfig({
|
|
207
211
|
timeout: 120_000,
|
|
212
|
+
// Capture surfaces in PARALLEL. StyleProof generates one test per surface × width,
|
|
213
|
+
// each an isolated page writing a uniquely-keyed file (\`<key>@<width>.json.gz\`), with
|
|
214
|
+
// per-page record/replay and frozen clock — so they're independent and safe to run
|
|
215
|
+
// concurrently. Without this, all surfaces sit in one spec file and capture serially;
|
|
216
|
+
// with it they fan out across workers, a near-linear speedup on a multi-surface app.
|
|
217
|
+
// (\`--shard\` splits them across CI machines too; they write disjoint files into one
|
|
218
|
+
// dir.) Tune \`workers\` to your machine if needed.
|
|
219
|
+
fullyParallel: true,
|
|
208
220
|
use: {
|
|
209
221
|
baseURL: process.env.BASE_URL || '${baseUrl}',
|
|
210
222
|
},
|
|
@@ -221,6 +233,70 @@ export default defineConfig({
|
|
|
221
233
|
});
|
|
222
234
|
`;
|
|
223
235
|
|
|
236
|
+
// The out-of-the-box gate: capture happens PRE-PUSH on your machine (where the app
|
|
237
|
+
// already builds + serves) and the lean computed-style map is committed, so main
|
|
238
|
+
// always carries a base map and CI is a fast, browser-less diff of two precomputed
|
|
239
|
+
// maps. The hook redirects capture into a committed `stylemaps/` dir and drops
|
|
240
|
+
// screenshots via the STYLEPROOF_BASEDIR / STYLEPROOF_SCREENSHOTS env knobs.
|
|
241
|
+
const HOOK_PATH = '.githooks/pre-push';
|
|
242
|
+
const PREPUSH_HOOK = `#!/bin/sh
|
|
243
|
+
# StyleProof pre-push hook (generated by styleproof-init).
|
|
244
|
+
#
|
|
245
|
+
# Captures this branch's computed-style map, commits it, and pushes it WITH your
|
|
246
|
+
# push — one \`git push\`, never two. So main always carries a base map and CI just
|
|
247
|
+
# diffs two precomputed maps (no browser). Maps are lean .json.gz under stylemaps/.
|
|
248
|
+
# Activate once per clone: git config core.hooksPath .githooks
|
|
249
|
+
set -e
|
|
250
|
+
|
|
251
|
+
remote="$1"
|
|
252
|
+
|
|
253
|
+
# Re-entry guard: the map commit is pushed by this hook (below), which fires the
|
|
254
|
+
# hook a second time — skip the expensive capture on that pass.
|
|
255
|
+
[ "\${STYLEPROOF_SKIP_CAPTURE:-}" = "1" ] && exit 0
|
|
256
|
+
|
|
257
|
+
STYLEMAP_DIR=current STYLEPROOF_BASEDIR=stylemaps STYLEPROOF_SCREENSHOTS=0 \\
|
|
258
|
+
npx playwright test ${specPath}
|
|
259
|
+
|
|
260
|
+
git add stylemaps
|
|
261
|
+
git diff --cached --quiet -- stylemaps && exit 0 # map unchanged — let the push proceed
|
|
262
|
+
|
|
263
|
+
# Map changed: commit it and push the updated branch ourselves so you never push
|
|
264
|
+
# twice (git pushes the pre-hook commit, so a new commit here wouldn't otherwise go).
|
|
265
|
+
# The re-entry guard stops the inner push from recapturing; then abort this original,
|
|
266
|
+
# now-stale push so nothing is sent twice.
|
|
267
|
+
git commit -m "chore(styleproof): update computed-style map"
|
|
268
|
+
echo "StyleProof: style map changed — committed it and pushing with your branch…" >&2
|
|
269
|
+
STYLEPROOF_SKIP_CAPTURE=1 git push "$remote" HEAD
|
|
270
|
+
echo "StyleProof: ✓ pushed your branch with the updated map — nothing more to do." >&2
|
|
271
|
+
echo "StyleProof: (git's 'failed to push some refs' below is expected; the push above already succeeded.)" >&2
|
|
272
|
+
exit 1
|
|
273
|
+
`;
|
|
274
|
+
|
|
275
|
+
const CI_PATH = '.github/workflows/styleproof.yml';
|
|
276
|
+
const CI_WORKFLOW = `name: StyleProof
|
|
277
|
+
|
|
278
|
+
# Maps are captured + committed pre-push (see .githooks/pre-push), so CI runs no
|
|
279
|
+
# browser — it just diffs this branch's committed map against the base branch's.
|
|
280
|
+
on: pull_request
|
|
281
|
+
|
|
282
|
+
permissions:
|
|
283
|
+
contents: read
|
|
284
|
+
|
|
285
|
+
jobs:
|
|
286
|
+
styleproof:
|
|
287
|
+
runs-on: ubuntu-latest
|
|
288
|
+
steps:
|
|
289
|
+
- uses: actions/checkout@v4
|
|
290
|
+
with:
|
|
291
|
+
fetch-depth: 0 # need the base ref to read its committed map
|
|
292
|
+
- uses: actions/setup-node@v4
|
|
293
|
+
with:
|
|
294
|
+
node-version: '20'
|
|
295
|
+
- run: npm ci
|
|
296
|
+
- name: Diff this branch's committed map against the base
|
|
297
|
+
run: npx styleproof-diff --base-ref "origin/\${{ github.event.pull_request.base.ref }}" stylemaps/current
|
|
298
|
+
`;
|
|
299
|
+
|
|
224
300
|
function writeFileSafe(file, contents, { force: f } = {}) {
|
|
225
301
|
const exists = fs.existsSync(file);
|
|
226
302
|
if (exists && !f) return { wrote: false, exists: true };
|
|
@@ -263,15 +339,34 @@ if (fs.existsSync(configPath) || fs.existsSync('playwright.config.js')) {
|
|
|
263
339
|
wroteSomething = true;
|
|
264
340
|
}
|
|
265
341
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
console.log(
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
console.log(
|
|
274
|
-
|
|
342
|
+
// Pre-push capture+commit hook — the out-of-the-box gate (capture local, CI diffs).
|
|
343
|
+
const hook = writeFileSafe(HOOK_PATH, PREPUSH_HOOK, { force });
|
|
344
|
+
if (hook.wrote) {
|
|
345
|
+
fs.chmodSync(HOOK_PATH, 0o755);
|
|
346
|
+
console.log(`${hook.exists ? 'overwrote' : 'created'} ${HOOK_PATH} (pre-push capture + commit)`);
|
|
347
|
+
wroteSomething = true;
|
|
348
|
+
} else {
|
|
349
|
+
console.log(`${HOOK_PATH} already exists — left untouched (use --force to overwrite)`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Browser-less CI diff — never overwrite an existing workflow.
|
|
353
|
+
const ci = writeFileSafe(CI_PATH, CI_WORKFLOW);
|
|
354
|
+
if (ci.wrote) {
|
|
355
|
+
console.log(`created ${CI_PATH} (browser-less CI diff)`);
|
|
356
|
+
wroteSomething = true;
|
|
357
|
+
} else {
|
|
358
|
+
console.log(`${CI_PATH} already exists — left untouched`);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
console.log('\nHow the gate works — capture local, CI just diffs:');
|
|
362
|
+
console.log(' 1. Activate the pre-push hook (once per clone):');
|
|
363
|
+
console.log(' git config core.hooksPath .githooks');
|
|
364
|
+
console.log(' 2. Push your branch. The hook captures the computed-style map against a');
|
|
365
|
+
console.log(' PRODUCTION build, commits it (lean .json.gz under stylemaps/), and pushes it');
|
|
366
|
+
console.log(' with your branch — one `git push`, never two.');
|
|
367
|
+
console.log(' 3. On the PR, CI diffs your committed map against the base branch — no browser,');
|
|
368
|
+
console.log(' just a fast comparison of precomputed maps. An empty diff certifies no change.');
|
|
369
|
+
console.log(' So main always carries a base map and every PR is a quick diff, not a recapture.');
|
|
275
370
|
|
|
276
371
|
if (!wroteSomething) console.log('\nnothing to write — project already scaffolded.');
|
|
277
372
|
process.exit(0);
|
|
@@ -10,13 +10,18 @@
|
|
|
10
10
|
* need the .png screenshots that `defineStyleMapCapture` saves by default.
|
|
11
11
|
* Exit code 0 = no changes, 1 = report generated, 2 = usage error.
|
|
12
12
|
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
13
14
|
import { generateStyleMapReport } from '../dist/report.js';
|
|
15
|
+
import { materializeRef, GitRefError } from '../dist/gitref.js';
|
|
14
16
|
|
|
15
17
|
const HELP = `styleproof-report — reviewable before/after report from two captures
|
|
16
18
|
|
|
17
19
|
usage: styleproof-report <beforeDir> <afterDir> --out <dir> [options]
|
|
20
|
+
styleproof-report --base-ref <gitref> <mapsDir> --out <dir> [options]
|
|
18
21
|
|
|
19
22
|
options:
|
|
23
|
+
--base-ref <ref> use <mapsDir> as committed at <ref> (e.g. main) as the
|
|
24
|
+
before side — base from git, no recapture
|
|
20
25
|
--out <dir> output directory (default: styleproof-report)
|
|
21
26
|
--image-base-url <url> prefix for image URLs in report.md (default: relative)
|
|
22
27
|
--pad <px> padding around changed rects when cropping (default: 24)
|
|
@@ -47,6 +52,7 @@ let minWidth;
|
|
|
47
52
|
let minHeight;
|
|
48
53
|
let includeLayoutNoise = false;
|
|
49
54
|
let includeContent = false;
|
|
55
|
+
let baseRef = null;
|
|
50
56
|
for (let i = 0; i < argv.length; i++) {
|
|
51
57
|
const a = argv[i];
|
|
52
58
|
if (a === '-h' || a === '--help') {
|
|
@@ -70,14 +76,36 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
70
76
|
else if (a.startsWith('--include-layout-noise=')) includeLayoutNoise = a.slice(23) !== 'false';
|
|
71
77
|
else if (a === '--include-content') includeContent = true;
|
|
72
78
|
else if (a.startsWith('--include-content=')) includeContent = a.slice(18) !== 'false';
|
|
79
|
+
else if (a === '--base-ref') baseRef = argv[++i];
|
|
80
|
+
else if (a.startsWith('--base-ref=')) baseRef = a.slice(11);
|
|
73
81
|
else if (a.startsWith('--')) {
|
|
74
82
|
console.error(`unknown flag: ${a}`);
|
|
75
83
|
process.exit(2);
|
|
76
84
|
} else args.push(a);
|
|
77
85
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
86
|
+
let beforeDir;
|
|
87
|
+
let afterDir;
|
|
88
|
+
let tmpBase = null;
|
|
89
|
+
if (baseRef) {
|
|
90
|
+
if (args.length !== 1) {
|
|
91
|
+
console.error('usage: styleproof-report --base-ref <gitref> <mapsDir> --out <dir> [options]');
|
|
92
|
+
process.exit(2);
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
tmpBase = materializeRef(baseRef, args[0]);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.error(e instanceof GitRefError ? `styleproof-report --base-ref: ${e.message}` : String(e?.message ?? e));
|
|
98
|
+
process.exit(2);
|
|
99
|
+
}
|
|
100
|
+
beforeDir = tmpBase;
|
|
101
|
+
afterDir = args[0];
|
|
102
|
+
} else {
|
|
103
|
+
if (args.length !== 2) {
|
|
104
|
+
console.error('usage: styleproof-report <beforeDir> <afterDir> --out <dir> [options] (--help for all options)');
|
|
105
|
+
process.exit(2);
|
|
106
|
+
}
|
|
107
|
+
beforeDir = args[0];
|
|
108
|
+
afterDir = args[1];
|
|
81
109
|
}
|
|
82
110
|
for (const [name, val] of [
|
|
83
111
|
['--pad', pad],
|
|
@@ -99,8 +127,8 @@ if (foldDetailsAt !== undefined && Number.isNaN(foldDetailsAt)) {
|
|
|
99
127
|
let result;
|
|
100
128
|
try {
|
|
101
129
|
result = generateStyleMapReport({
|
|
102
|
-
beforeDir
|
|
103
|
-
afterDir
|
|
130
|
+
beforeDir,
|
|
131
|
+
afterDir,
|
|
104
132
|
outDir: flags.out,
|
|
105
133
|
imageBaseUrl: flags.imageBaseUrl || undefined,
|
|
106
134
|
pad,
|
|
@@ -114,6 +142,8 @@ try {
|
|
|
114
142
|
} catch (e) {
|
|
115
143
|
console.error(e.message);
|
|
116
144
|
process.exit(2);
|
|
145
|
+
} finally {
|
|
146
|
+
if (tmpBase) fs.rmSync(tmpBase, { recursive: true, force: true });
|
|
117
147
|
}
|
|
118
148
|
|
|
119
149
|
const newNote = result.newSurfaces ? ` (+${result.newSurfaces} new surface(s) with no baseline)` : '';
|
|
@@ -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/gitref.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** A readable failure materialising a base from git — CLIs map this to exit 2. */
|
|
2
|
+
export declare class GitRefError extends Error {
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Materialise the captures committed at `dir` as of `ref` into a fresh temp dir,
|
|
6
|
+
* so a diff/report can use e.g. `main`'s committed map as the base with no checkout
|
|
7
|
+
* and no recapture — the "base map lives on main, CI just diffs" model.
|
|
8
|
+
*
|
|
9
|
+
* Reads purely through git (`ls-tree`/`show`, no `tar`/deps); binary `.json.gz`
|
|
10
|
+
* bytes come straight from `git show`. The caller owns cleanup of the returned dir.
|
|
11
|
+
* Throws {@link GitRefError} (never exits) so it's reusable from any entry point.
|
|
12
|
+
*/
|
|
13
|
+
export declare function materializeRef(ref: string, dir: string): string;
|
package/dist/gitref.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
/** A readable failure materialising a base from git — CLIs map this to exit 2. */
|
|
6
|
+
export class GitRefError extends Error {
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Materialise the captures committed at `dir` as of `ref` into a fresh temp dir,
|
|
10
|
+
* so a diff/report can use e.g. `main`'s committed map as the base with no checkout
|
|
11
|
+
* and no recapture — the "base map lives on main, CI just diffs" model.
|
|
12
|
+
*
|
|
13
|
+
* Reads purely through git (`ls-tree`/`show`, no `tar`/deps); binary `.json.gz`
|
|
14
|
+
* bytes come straight from `git show`. The caller owns cleanup of the returned dir.
|
|
15
|
+
* Throws {@link GitRefError} (never exits) so it's reusable from any entry point.
|
|
16
|
+
*/
|
|
17
|
+
export function materializeRef(ref, dir) {
|
|
18
|
+
const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', maxBuffer: 1 << 28 });
|
|
19
|
+
if (top.status !== 0)
|
|
20
|
+
throw new GitRefError('must run inside a git repository');
|
|
21
|
+
const toplevel = top.stdout.trim();
|
|
22
|
+
// -z: NUL-separated, unquoted paths (handles spaces/unicode).
|
|
23
|
+
const ls = spawnSync('git', ['ls-tree', '-r', '-z', '--name-only', ref, '--', dir], {
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
maxBuffer: 1 << 28,
|
|
26
|
+
});
|
|
27
|
+
if (ls.status !== 0)
|
|
28
|
+
throw new GitRefError(`cannot read ${dir} at ${ref}: ${(ls.stderr || '').trim()}`);
|
|
29
|
+
const files = ls.stdout.split('\0').filter(Boolean);
|
|
30
|
+
if (files.length === 0) {
|
|
31
|
+
throw new GitRefError(`no committed captures at ${dir} in ${ref} — commit the maps so the base lives in git`);
|
|
32
|
+
}
|
|
33
|
+
const dirRepoRel = path.relative(toplevel, path.resolve(dir));
|
|
34
|
+
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'styleproof-baseref-'));
|
|
35
|
+
for (const f of files) {
|
|
36
|
+
const show = spawnSync('git', ['show', `${ref}:${f}`], { maxBuffer: 1 << 28 }); // buffer — preserves .json.gz
|
|
37
|
+
if (show.status !== 0)
|
|
38
|
+
throw new GitRefError(`cannot read ${f} at ${ref}`);
|
|
39
|
+
const out = path.join(dest, path.relative(dirRepoRel, f));
|
|
40
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
41
|
+
fs.writeFileSync(out, show.stdout);
|
|
42
|
+
}
|
|
43
|
+
return dest;
|
|
44
|
+
}
|
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.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",
|
|
@@ -61,7 +61,8 @@
|
|
|
61
61
|
"format:check": "prettier --check \"{src,bin,test,example}/**/*.{ts,mjs,js}\" \"*.{ts,js,mjs,json,md}\"",
|
|
62
62
|
"test": "npm run build && node --test test/*.test.mjs",
|
|
63
63
|
"test:watch": "node --test --watch test/*.test.mjs",
|
|
64
|
-
"test:e2e": "npm run build && playwright test
|
|
64
|
+
"test:e2e": "npm run build && playwright test",
|
|
65
|
+
"bench": "npm run build && node bench/gate-speed.mjs",
|
|
65
66
|
"init": "node ./bin/styleproof-init.mjs",
|
|
66
67
|
"prepare": "npm run build && husky",
|
|
67
68
|
"prepublishOnly": "npm run clean && npm run build && npm run typecheck"
|