styleproof 3.0.2 → 3.1.1
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 +84 -1
- package/README.md +41 -7
- package/bin/styleproof-diff.mjs +46 -28
- package/bin/styleproof-init.mjs +140 -21
- package/bin/styleproof-map.mjs +116 -0
- package/bin/styleproof-report.mjs +32 -23
- package/dist/capture.js +30 -9
- package/dist/cli-base-ref.d.ts +15 -0
- package/dist/cli-base-ref.js +53 -0
- package/dist/cli-errors.d.ts +10 -0
- package/dist/cli-errors.js +50 -0
- package/dist/diff.d.ts +1 -0
- package/dist/diff.js +20 -4
- package/dist/gitref.d.ts +2 -0
- package/dist/gitref.js +45 -1
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,87 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.1.1] - 2026-06-27
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Release workflow now verifies npm publication before tagging.** A release
|
|
15
|
+
fails before creating tags or GitHub Releases if `NPM_TOKEN` is missing, and
|
|
16
|
+
it verifies `styleproof@<version>` on npm after publishing or detecting an
|
|
17
|
+
already-published version.
|
|
18
|
+
- **Generated pre-push hooks now restore no-op map churn before committing.** The
|
|
19
|
+
hook captures through `styleproof-map`, runs a semantic diff against `HEAD`, and
|
|
20
|
+
restores `stylemaps/` when the refreshed map is identical. When the map really
|
|
21
|
+
changes, the hook prints the diff plus a concrete live-state/replay hint before
|
|
22
|
+
creating the map commit.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **Real cursor hover no longer contaminates the resting style map.** Capture now
|
|
27
|
+
parks the mouse over an ignored 1px hover sink before reading, matching the
|
|
28
|
+
existing focus blur behavior. The canonical resting map stays unhovered while
|
|
29
|
+
the forced `:hover` layer still records the hover delta.
|
|
30
|
+
- **`styleproof-map` now writes lean committed maps by default.** Recorded HAR
|
|
31
|
+
files are removed after successful capture so private API responses are not
|
|
32
|
+
accidentally committed by the default pre-push flow. Use `--keep-har` or
|
|
33
|
+
`STYLEPROOF_KEEP_HAR=1` for explicit record/replay workflows.
|
|
34
|
+
- **Generated pre-push hooks now clean untracked no-op artifacts.** When the
|
|
35
|
+
refreshed map is semantically identical to `HEAD`, the hook restores tracked
|
|
36
|
+
`stylemaps/` files and removes untracked files under `stylemaps/current`, so a
|
|
37
|
+
clean push leaves no generated capture debris behind.
|
|
38
|
+
|
|
39
|
+
## [3.1.0] - 2026-06-27
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- **`styleproof-init` now follows the repo's package manager.** Generated
|
|
44
|
+
Playwright configs, pre-push hooks, and browser-less CI workflows detect
|
|
45
|
+
`bun.lock`/`bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, or `package-lock.json`
|
|
46
|
+
and scaffold matching build, install, `styleproof-map`, and `styleproof-diff`
|
|
47
|
+
commands instead of assuming npm everywhere.
|
|
48
|
+
- **The committed-map flow is now a three-command CLI path.** Run
|
|
49
|
+
`styleproof-init`, `styleproof-map`, then `styleproof-diff`. `styleproof-map`
|
|
50
|
+
captures the current branch into `stylemaps/current`, while `styleproof-diff`
|
|
51
|
+
now defaults to that map directory and infers the base ref from GitHub Actions,
|
|
52
|
+
`branch.<name>.gh-merge-base`, `origin/main`, `origin/master`, `main`, or
|
|
53
|
+
`master` (or accepts `styleproof-diff main` / `--base-ref main`). The zero-diff
|
|
54
|
+
success line says `0 changed surfaces across N captured surface(s)` instead of
|
|
55
|
+
the confusing `N surfaces identical` / `0 surfaces identical` wording, and the
|
|
56
|
+
`--json` payload includes `compared` so consumers can show the same count.
|
|
57
|
+
- **`styleproof-report` now mirrors the diff CLI defaults.** Run
|
|
58
|
+
`styleproof-report` with no args to generate the side-by-side report from
|
|
59
|
+
`stylemaps/current` against the inferred base branch, or pass
|
|
60
|
+
`styleproof-report main` / `--base-ref main` to pin the base while keeping the
|
|
61
|
+
committed-map directory default.
|
|
62
|
+
- **No-arg diff/report now understand stacked PRs locally.** After
|
|
63
|
+
`GITHUB_BASE_REF` and explicit `branch.<name>.gh-merge-base` config, the shared
|
|
64
|
+
base-ref inference asks `gh pr view` for the current PR base before falling back
|
|
65
|
+
to `main`/`master`, so stacked branches compare against their real review base
|
|
66
|
+
out of the box.
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
- **CLI errors now lead with the recovery step.** Missing specs, unknown flags,
|
|
71
|
+
absent working maps, and missing committed base maps now print a concrete
|
|
72
|
+
`Next:` line such as `run styleproof-map`, pass `--maps-dir <dir>`, or commit
|
|
73
|
+
captures on the base branch.
|
|
74
|
+
- **`styleproof-map` now runs generated capture tests correctly.** The CLI no
|
|
75
|
+
longer passes the spec path as a Playwright file filter, because StyleProof's
|
|
76
|
+
generated tests are registered through the package runner. It now targets the
|
|
77
|
+
capture suite with Playwright's grep path, matching the public
|
|
78
|
+
`styleproof-init` → `styleproof-map` → `styleproof-diff` flow.
|
|
79
|
+
- **Clean StyleProof PR runs now leave a visible receipt.** The Action creates or
|
|
80
|
+
updates its PR comment with `No visual changes detected.` even when there is no
|
|
81
|
+
existing report comment yet, and `styleproof-init` scaffolds the same
|
|
82
|
+
no-change PR comment for the browser-less committed-map workflow.
|
|
83
|
+
- **Layout-equivalent auto-margin drift no longer creates phantom diffs.** Some
|
|
84
|
+
browser/forced-state combinations can report horizontal `margin-left` /
|
|
85
|
+
`margin-right` / logical margin equivalents differently even when the captured
|
|
86
|
+
document-space rectangle is unchanged. StyleProof now drops only those
|
|
87
|
+
margin-longhand differences when the element's rect is identical on both sides,
|
|
88
|
+
including inside forced `:hover`/`:focus`/`:active` deltas. If the rect moves,
|
|
89
|
+
the margin change still reports.
|
|
90
|
+
|
|
10
91
|
## [3.0.2] - 2026-06-27
|
|
11
92
|
|
|
12
93
|
### Fixed
|
|
@@ -841,7 +922,9 @@ number)`), so each viewport band can capture at its own height. Default remains
|
|
|
841
922
|
- `styleproof-diff` CLI: certifies a refactor (exit 0) or names the exact element,
|
|
842
923
|
property, and state that drifted (exit 1).
|
|
843
924
|
|
|
844
|
-
[Unreleased]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.
|
|
925
|
+
[Unreleased]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.1...HEAD
|
|
926
|
+
[3.1.1]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.0...v3.1.1
|
|
927
|
+
[3.1.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.0.2...v3.1.0
|
|
845
928
|
[3.0.2]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.0.1...v3.0.2
|
|
846
929
|
[3.0.1]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.0.0...v3.0.1
|
|
847
930
|
[3.0.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v2.5.0...v3.0.0
|
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ On every PR, StyleProof diffs the PR head's computed-style map against the base
|
|
|
16
16
|
|
|
17
17
|
- A **lean summary comment** linking to a committed side-by-side report — the report is the complete source of truth (**one section per distinct change**, with a before/after cropped screenshot cropped from the same rectangle so the two sides line up exactly, **plain-English bullets that tell you what to look for** — `columns: 2 → 3`, `recoloured cyan → amber` — and the exact property changes). The comment never duplicates the report, so the two can't drift, and it renders identically on public and private repos.
|
|
18
18
|
- A single **Approve all changes** checkbox in the comment, driving a `StyleProof` commit status: red until one tick signs off every change, green when there are none. The reviewer who ticks it is recorded inline (_approved by @them_), sourced from the commit status so it survives a report re-run.
|
|
19
|
+
- **Clean runs leave a receipt too.** When no visual changes are detected, StyleProof still creates or updates the PR comment with `✓ No visual changes detected.`, so a green status has a visible record instead of only a silent check.
|
|
19
20
|
- **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 in the report under a `🆕 new surface` heading but never holds the status red and needs no sign-off. It becomes part of the baseline once merged.
|
|
20
21
|
- The diff is always **head-vs-base**, so the report is _exactly what this PR changes_ — whether the maps are captured fresh in CI or (by default) committed pre-push and diffed browser-free in CI. See [Quickstart](#quickstart).
|
|
21
22
|
|
|
@@ -167,7 +168,7 @@ It scaffolds:
|
|
|
167
168
|
|
|
168
169
|
- a **capture spec** (`e2e/styleproof.spec.ts`) describing your surfaces (a Next.js app gets its routes _and_ the coverage guard wired automatically — see below);
|
|
169
170
|
- a **`playwright.config.ts`** that builds and serves a **production build** (never a flaky dev server) and captures surfaces **in parallel** (`fullyParallel`);
|
|
170
|
-
- a **pre-push hook** (`.githooks/pre-push`, activated for you via `core.hooksPath` — it won't clobber an existing husky setup) that captures the map, commits
|
|
171
|
+
- a **pre-push hook** (`.githooks/pre-push`, activated for you via `core.hooksPath` — it won't clobber an existing husky setup) that captures the map, restores no-op map churn, commits real map changes as lean `.json.gz` files under `stylemaps/`, and **pushes them with your branch — one `git push`, never two**;
|
|
171
172
|
- a **browser-less CI workflow** that diffs your committed map against the base branch.
|
|
172
173
|
|
|
173
174
|
Describe your surfaces — **omit `widths`** and StyleProof sweeps your real `@media` breakpoints automatically:
|
|
@@ -187,11 +188,36 @@ defineStyleMapCapture({
|
|
|
187
188
|
});
|
|
188
189
|
```
|
|
189
190
|
|
|
190
|
-
|
|
191
|
+
The manual loop is deliberately three commands:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
npx styleproof-init
|
|
195
|
+
npx styleproof-map
|
|
196
|
+
npx styleproof-diff
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`styleproof-map` captures this branch into `stylemaps/current` as lean committed
|
|
200
|
+
maps: HAR recordings are removed by default so private API responses do not land
|
|
201
|
+
in git. Keep them only for an explicit record/replay workflow with
|
|
202
|
+
`styleproof-map --keep-har` (or `STYLEPROOF_KEEP_HAR=1`). `styleproof-diff` then
|
|
203
|
+
compares the committed map against the base branch automatically: in GitHub
|
|
204
|
+
Actions it uses the PR base; locally it checks `branch.<name>.gh-merge-base`,
|
|
205
|
+
then the current GitHub PR base via `gh pr view` (handy for stacked PRs), then
|
|
206
|
+
`origin/main`, `origin/master`, `main`, and `master`. Pin it when needed with
|
|
207
|
+
`styleproof-diff main`, `styleproof-diff master`, or `styleproof-diff --base-ref
|
|
208
|
+
origin/my-base`.
|
|
209
|
+
|
|
210
|
+
**That's the whole loop.** Capture happens **pre-push on your machine** (where the app already builds and serves); the hook first diffs the refreshed map against `HEAD` and restores it when the semantic map is unchanged, otherwise it commits and pushes the lean map; and **CI is a browser-less diff of two precomputed maps** — no build, no browser, just a fast comparison (~5400× cheaper on the compare step than re-capturing both sides in CI). `main` always carries a base map, so every PR is _your code vs the committed base_. Require the `StyleProof` status in branch protection and an unverified visual change can't merge.
|
|
191
211
|
|
|
192
212
|
> **Same-environment note.** Computed styles depend on the browser build and installed fonts, so maps are only comparable when captured in the same environment (your machine, or a pinned container). The self-check fails loudly on a non-deterministic capture, so drift never passes silently.
|
|
193
213
|
|
|
194
|
-
**Want the side-by-side report
|
|
214
|
+
**Want the local side-by-side report** (not just a pass/fail diff)? Run `npx
|
|
215
|
+
styleproof-report` after `styleproof-map`; it uses the same inferred base ref and
|
|
216
|
+
`stylemaps/current` defaults as `styleproof-diff`. Pin the base with
|
|
217
|
+
`styleproof-report main` or keep the manual form with `styleproof-report before
|
|
218
|
+
after --out report`.
|
|
219
|
+
|
|
220
|
+
**Want the side-by-side report + one-click approval**? Use the Action, pointing it at the base ref:
|
|
195
221
|
|
|
196
222
|
```yaml
|
|
197
223
|
# .github/workflows/styleproof.yml
|
|
@@ -229,6 +255,12 @@ Copy both `capture` and `report` files to `.github/workflows/` (the `report` one
|
|
|
229
255
|
- **Frozen clock.** `Date.now()` / `new Date()` are pinned to a fixed instant, so time-derived styling (`stale > 1h → red`) can't drift. Timers keep running, so settling still works.
|
|
230
256
|
- **Self-check** — captures each surface twice and fails if they differ, so a replay gap or unseeded randomness surfaces as a clear _"non-deterministic capture"_ error, never as a phantom change on an unrelated PR. **On by default while recording** (where live nondeterminism shows up); off on the replay run, which renders against the recorded HAR and is deterministic by construction. `STYLEPROOF_SELFCHECK=1` forces it on for both; `selfCheck: false` opts out.
|
|
231
257
|
- **Framework noise is skipped by default.** Non-visual and framework-injected elements never count as a change — `<meta>`/`<title>`/`<script>`/`<style>`/… (which Next.js streams into the body then hoists) and live regions like Next's `next-route-announcer`. A real stylesheet change still shows up in the affected elements' computed styles, not in the `<style>` tag. Add your own selectors with `ignore` — they extend this default, they don't replace it.
|
|
258
|
+
- **Layout-equivalent margin noise is normalised.** If the browser reports
|
|
259
|
+
horizontal auto-centering margins (`margin-left`/`margin-right` and logical
|
|
260
|
+
equivalents) differently but the captured document-space rectangle is
|
|
261
|
+
identical, StyleProof treats that as the same rendered layout, including in
|
|
262
|
+
forced `:hover`/`:focus`/`:active` deltas. If the box moves or resizes, the
|
|
263
|
+
margin change still reports.
|
|
232
264
|
|
|
233
265
|
> Replay covers data the page _fetches_. If your app **server-renders** differently per environment (SSR feature flags, locale), still capture both sides with the same server env so the rendered HTML matches.
|
|
234
266
|
|
|
@@ -241,9 +273,10 @@ Anything still moving on its own after that is detected as a volatile region and
|
|
|
241
273
|
| Handled for you — zero config | How |
|
|
242
274
|
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
243
275
|
| In-flight data, fonts, late layout | network-aware settle holds until requests finish _and_ the computed styles stop changing |
|
|
244
|
-
| Animations, transitions, focus
|
|
276
|
+
| Animations, transitions, real hover/focus, caret | frozen / neutralised before the map is read; forced states are captured separately |
|
|
245
277
|
| Clock-derived styling (`stale > 1h → red`) | `Date.now()` / `new Date()` frozen to a fixed instant |
|
|
246
278
|
| Framework & non-visual noise (`<script>`, route announcers) | skipped by default |
|
|
279
|
+
| Layout-equivalent horizontal auto margins | ignored only when the captured element rectangle is unchanged |
|
|
247
280
|
| Semantic live-state candidates (`aria-live`, `role=status`) | auto-detected and kept in the diff when stable |
|
|
248
281
|
| Live / volatile regions (tickers, third-party embeds) | auto-detected as still-moving and excluded from direct element comparison |
|
|
249
282
|
| Non-deterministic capture (replay gap, unseeded randomness) | self-check flags it _while recording_, with a named error |
|
|
@@ -359,9 +392,10 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
|
|
|
359
392
|
|
|
360
393
|
**CLIs** (every flag accepts `--flag value` and `--flag=value`; `--help` lists all):
|
|
361
394
|
|
|
362
|
-
- `styleproof-init` — scaffold **and activate** the whole gate: the capture spec, a `playwright.config.ts` (production-build `webServer`, parallel capture), the pre-push capture-and-push hook, and the browser-less CI workflow. One command.
|
|
363
|
-
- `styleproof-
|
|
364
|
-
- `styleproof-
|
|
395
|
+
- `styleproof-init` — scaffold **and activate** the whole gate: the capture spec, a `playwright.config.ts` (production-build `webServer`, parallel capture), the pre-push capture-and-push hook, and the browser-less CI workflow. One command. Generated commands follow the repo's lockfile (`bun.lock`/`bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, or npm by default) instead of assuming npm.
|
|
396
|
+
- `styleproof-map` — capture the current branch's computed-style map through Playwright. By default it writes lean committed maps to `stylemaps/current`; pass `--spec`, `--dir`, `--base-dir`, or `--screenshots` when you need a custom capture.
|
|
397
|
+
- `styleproof-diff` — the certify gate. With no args, it diffs `stylemaps/current` against the inferred base branch (`GITHUB_BASE_REF`, `branch.<name>.gh-merge-base`, `gh pr view`, then main/master fallbacks); `styleproof-diff main` / `styleproof-diff master` pins the base; `styleproof-diff <beforeDir> <afterDir>` keeps the manual two-directory form. Exits `0` certified (identical), `1` on a diff, `2` on a usage/capture error, `3` when only new surfaces are present (no baseline to diff against). A clean run prints `0 changed surfaces across N captured surface(s)`, and `--json` includes `compared`.
|
|
398
|
+
- `styleproof-report` — render the diff to a Markdown report with before/after crops. With no args, it reports `stylemaps/current` against the inferred base branch; `styleproof-report main` / `styleproof-report master` pins the base; `styleproof-report <beforeDir> <afterDir> --out <dir>` keeps the manual two-directory form. Add `--include-content` for the opt-in, advisory content section (see above).
|
|
365
399
|
|
|
366
400
|
A programmatic API is also exported — `captureStyleMap`, `diffStyleMaps`, `generateStyleMapReport`, and the breakpoint helpers `detectViewportWidths` / `widthsFromBoundaries`, among others. For the capture internals, the approve-workflow trust model, and how to contribute, see [CONTRIBUTING](https://github.com/BenSheridanEdwards/StyleProof/blob/main/CONTRIBUTING.md) and the [`example/`](https://github.com/BenSheridanEdwards/StyleProof/tree/main/example) workflows.
|
|
367
401
|
|
package/bin/styleproof-diff.mjs
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Diff two computed-style map captures (see styleproof).
|
|
4
4
|
*
|
|
5
|
+
* styleproof-diff [baseRef] [--maps-dir <dir>] [--max N] [--json <file>]
|
|
6
|
+
* styleproof-diff --base-ref <gitref> [mapsDir] [--max N] [--json <file>]
|
|
5
7
|
* styleproof-diff <beforeDir> <afterDir> [--max N] [--json <file>]
|
|
6
|
-
* styleproof-diff --base-ref <gitref> <mapsDir> [--max N] [--json <file>]
|
|
7
8
|
*
|
|
8
9
|
* Reports, per surface:
|
|
9
10
|
* - DOM changes (elements added/removed/retagged) — a CSS-only refactor
|
|
@@ -26,17 +27,31 @@
|
|
|
26
27
|
* error, 3 = only new surfaces (present on one side, no baseline to diff against).
|
|
27
28
|
*/
|
|
28
29
|
import fs from 'node:fs';
|
|
30
|
+
import path from 'node:path';
|
|
29
31
|
import { diffStyleMapDirs, findingLabel } from '../dist/diff.js';
|
|
30
|
-
import {
|
|
32
|
+
import { cleanupBaseRefCaptureDirs, resolveBaseRefCaptureDirs } from '../dist/cli-base-ref.js';
|
|
33
|
+
import {
|
|
34
|
+
cliErrorMessage,
|
|
35
|
+
isHelpArg,
|
|
36
|
+
missingManualCaptureMessage,
|
|
37
|
+
showHelpAndExit,
|
|
38
|
+
unknownFlagMessage,
|
|
39
|
+
} from '../dist/cli-errors.js';
|
|
31
40
|
|
|
32
|
-
const
|
|
41
|
+
const COMMAND = path.basename(process.argv[1] ?? 'styleproof-diff').replace(/\.mjs$/, '');
|
|
42
|
+
const DEFAULT_MAPS_DIR = 'stylemaps/current';
|
|
33
43
|
|
|
34
|
-
|
|
35
|
-
|
|
44
|
+
const HELP = `${COMMAND} — certify a CSS refactor by diffing two computed-style map captures
|
|
45
|
+
|
|
46
|
+
usage: ${COMMAND} [baseRef] [options]
|
|
47
|
+
${COMMAND} --base-ref <gitref> [mapsDir] [options]
|
|
48
|
+
${COMMAND} <beforeDir> <afterDir> [options]
|
|
36
49
|
|
|
37
50
|
options:
|
|
38
51
|
--base-ref <ref> diff <mapsDir> as committed at <ref> (e.g. main) against your
|
|
39
52
|
working <mapsDir> — base from git, no recapture
|
|
53
|
+
--maps-dir <dir> committed map dir for the base-ref flow
|
|
54
|
+
(default: ${DEFAULT_MAPS_DIR})
|
|
40
55
|
--max <n> max lines printed per surface before truncating (default: 40)
|
|
41
56
|
--json <file> also write the full structured diff to <file>
|
|
42
57
|
-h, --help show this help
|
|
@@ -50,51 +65,54 @@ const args = [];
|
|
|
50
65
|
let MAX = 40;
|
|
51
66
|
let jsonOut = null;
|
|
52
67
|
let baseRef = null;
|
|
68
|
+
let mapsDir = DEFAULT_MAPS_DIR;
|
|
53
69
|
for (let i = 0; i < argv.length; i++) {
|
|
54
|
-
if (argv[i]
|
|
55
|
-
|
|
56
|
-
process.exit(0);
|
|
57
|
-
} else if (argv[i] === '--max') MAX = Number(argv[++i]);
|
|
70
|
+
if (isHelpArg(argv[i])) showHelpAndExit(HELP);
|
|
71
|
+
else if (argv[i] === '--max') MAX = Number(argv[++i]);
|
|
58
72
|
else if (argv[i].startsWith('--max=')) MAX = Number(argv[i].slice(6));
|
|
59
73
|
else if (argv[i] === '--json') jsonOut = argv[++i];
|
|
60
74
|
else if (argv[i].startsWith('--json=')) jsonOut = argv[i].slice(7);
|
|
61
75
|
else if (argv[i] === '--base-ref') baseRef = argv[++i];
|
|
62
76
|
else if (argv[i].startsWith('--base-ref=')) baseRef = argv[i].slice(11);
|
|
77
|
+
else if (argv[i] === '--maps-dir') mapsDir = argv[++i];
|
|
78
|
+
else if (argv[i].startsWith('--maps-dir=')) mapsDir = argv[i].slice(11);
|
|
63
79
|
else if (argv[i].startsWith('--')) {
|
|
64
|
-
console.error(
|
|
80
|
+
console.error(unknownFlagMessage(COMMAND, argv[i]));
|
|
65
81
|
process.exit(2);
|
|
66
82
|
} else args.push(argv[i]);
|
|
67
83
|
}
|
|
68
84
|
|
|
69
85
|
let dirA;
|
|
70
86
|
let dirB;
|
|
71
|
-
let
|
|
72
|
-
if (baseRef) {
|
|
73
|
-
if (
|
|
74
|
-
console.error(
|
|
75
|
-
process.exit(2);
|
|
76
|
-
}
|
|
77
|
-
if (!fs.existsSync(args[0])) {
|
|
78
|
-
console.error(`no capture at ${args[0]}`);
|
|
87
|
+
let baseCapture = null;
|
|
88
|
+
if (baseRef || args.length <= 1) {
|
|
89
|
+
if (!Number.isFinite(MAX)) {
|
|
90
|
+
console.error(`usage: ${COMMAND} [baseRef] [--maps-dir <dir>] [--max N] [--json <file>]`);
|
|
79
91
|
process.exit(2);
|
|
80
92
|
}
|
|
81
93
|
try {
|
|
82
|
-
|
|
94
|
+
baseCapture = resolveBaseRefCaptureDirs({
|
|
95
|
+
command: COMMAND,
|
|
96
|
+
baseRef,
|
|
97
|
+
mapsDir,
|
|
98
|
+
args,
|
|
99
|
+
usage: `usage: ${COMMAND} --base-ref <gitref> [mapsDir] [--max N] [--json <file>]`,
|
|
100
|
+
});
|
|
83
101
|
} catch (e) {
|
|
84
|
-
console.error(
|
|
102
|
+
console.error(cliErrorMessage(e));
|
|
85
103
|
process.exit(2);
|
|
86
104
|
}
|
|
87
|
-
dirA =
|
|
88
|
-
dirB =
|
|
105
|
+
dirA = baseCapture.beforeDir;
|
|
106
|
+
dirB = baseCapture.afterDir;
|
|
89
107
|
} else {
|
|
90
108
|
if (args.length !== 2 || !Number.isFinite(MAX)) {
|
|
91
|
-
console.error(
|
|
109
|
+
console.error(`usage: ${COMMAND} <beforeDir> <afterDir> [--max N] [--json <file>] (--help for all options)`);
|
|
92
110
|
process.exit(2);
|
|
93
111
|
}
|
|
94
112
|
[dirA, dirB] = args;
|
|
95
113
|
for (const d of [dirA, dirB]) {
|
|
96
114
|
if (!fs.existsSync(d)) {
|
|
97
|
-
console.error(
|
|
115
|
+
console.error(missingManualCaptureMessage(COMMAND, d));
|
|
98
116
|
process.exit(2);
|
|
99
117
|
}
|
|
100
118
|
}
|
|
@@ -107,9 +125,9 @@ try {
|
|
|
107
125
|
console.error(e.message);
|
|
108
126
|
process.exit(2);
|
|
109
127
|
} finally {
|
|
110
|
-
|
|
128
|
+
cleanupBaseRefCaptureDirs(baseCapture);
|
|
111
129
|
}
|
|
112
|
-
const { surfaces, counts } = result;
|
|
130
|
+
const { surfaces, counts, compared } = result;
|
|
113
131
|
|
|
114
132
|
for (const sd of surfaces) {
|
|
115
133
|
if (sd.missing) {
|
|
@@ -138,7 +156,7 @@ for (const sd of surfaces) {
|
|
|
138
156
|
if (lines.length > MAX) console.log(` ... and ${lines.length - MAX} more lines (re-run with --max ${lines.length})`);
|
|
139
157
|
}
|
|
140
158
|
|
|
141
|
-
if (jsonOut) fs.writeFileSync(jsonOut, JSON.stringify({ counts, surfaces }, null, 2));
|
|
159
|
+
if (jsonOut) fs.writeFileSync(jsonOut, JSON.stringify({ counts, surfaces, compared }, null, 2));
|
|
142
160
|
|
|
143
161
|
const total = counts.dom + counts.style + counts.state;
|
|
144
162
|
const newSurfaces = surfaces.filter((s) => s.missing).length;
|
|
@@ -148,7 +166,7 @@ const newNote = newSurfaces ? ` (+${newSurfaces} new surface(s) with no baseline
|
|
|
148
166
|
console.log(
|
|
149
167
|
total === 0
|
|
150
168
|
? newSurfaces === 0
|
|
151
|
-
? `\n✓ ${
|
|
169
|
+
? `\n✓ 0 changed surfaces across ${compared} captured surface(s): every computed style, pseudo-element, and hover/focus/active state matches`
|
|
152
170
|
: `\nℹ ${newSurfaces} new surface(s) captured with no baseline to compare — shown for reference, no reviewable change`
|
|
153
171
|
: `\n✗ ${counts.dom} DOM change(s), ${counts.style} computed-style difference(s), ${counts.state} state-delta difference(s) across ${surfaceCount} surfaces${newNote}`,
|
|
154
172
|
);
|
package/bin/styleproof-init.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import fs from 'node:fs';
|
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
import { spawnSync } from 'node:child_process';
|
|
24
24
|
import { discoverNextRoutes } from '../dist/index.js';
|
|
25
|
+
import { isHelpArg, showHelpAndExit } from '../dist/cli-errors.js';
|
|
25
26
|
|
|
26
27
|
const HELP = `styleproof-init — scaffold a styleproof capture spec
|
|
27
28
|
|
|
@@ -42,13 +43,11 @@ What it writes:
|
|
|
42
43
|
- playwright.config.ts, only if absent (an existing one is left untouched)
|
|
43
44
|
|
|
44
45
|
After running, capture a baseline against a PRODUCTION build:
|
|
45
|
-
|
|
46
|
+
npx styleproof-map
|
|
46
47
|
|
|
47
48
|
To certify a refactor:
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
STYLEMAP_DIR=after npx playwright test styleproof
|
|
51
|
-
npx styleproof-diff __stylemaps__/before __stylemaps__/after
|
|
49
|
+
npx styleproof-map
|
|
50
|
+
npx styleproof-diff
|
|
52
51
|
`;
|
|
53
52
|
|
|
54
53
|
const argv = process.argv.slice(2);
|
|
@@ -57,10 +56,8 @@ let baseUrl = 'http://localhost:3000';
|
|
|
57
56
|
let force = false;
|
|
58
57
|
for (let i = 0; i < argv.length; i++) {
|
|
59
58
|
const a = argv[i];
|
|
60
|
-
if (a
|
|
61
|
-
|
|
62
|
-
process.exit(0);
|
|
63
|
-
} else if (a === '--dir') specPath = argv[++i];
|
|
59
|
+
if (isHelpArg(a)) showHelpAndExit(HELP);
|
|
60
|
+
else if (a === '--dir') specPath = argv[++i];
|
|
64
61
|
else if (a.startsWith('--dir=')) specPath = a.slice(6);
|
|
65
62
|
else if (a === '--base-url') baseUrl = argv[++i];
|
|
66
63
|
else if (a.startsWith('--base-url=')) baseUrl = a.slice(11);
|
|
@@ -110,10 +107,8 @@ const HEADER = `/**
|
|
|
110
107
|
* detects your @media breakpoints from the loaded CSS and sweeps one viewport per
|
|
111
108
|
* band — no config. Capture against a PRODUCTION build — dev servers inject styles.
|
|
112
109
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* STYLEMAP_DIR=after npx playwright test styleproof # capture again
|
|
116
|
-
* npx styleproof-diff __stylemaps__/before __stylemaps__/after
|
|
110
|
+
* npx styleproof-map # capture this branch into stylemaps/current
|
|
111
|
+
* npx styleproof-diff # compare against the inferred base branch
|
|
117
112
|
*/`;
|
|
118
113
|
|
|
119
114
|
// Next.js detected: derive both surfaces and the coverage guard from the app's
|
|
@@ -198,6 +193,62 @@ defineStyleMapCapture({
|
|
|
198
193
|
});
|
|
199
194
|
`;
|
|
200
195
|
|
|
196
|
+
const PACKAGE_MANAGERS = {
|
|
197
|
+
npm: {
|
|
198
|
+
label: 'npm',
|
|
199
|
+
run: (script) => `npm run ${script}`,
|
|
200
|
+
exec: (command) => `npx ${command}`,
|
|
201
|
+
install: 'npm ci',
|
|
202
|
+
setup: ` - uses: actions/setup-node@v4
|
|
203
|
+
with:
|
|
204
|
+
node-version: '20'
|
|
205
|
+
cache: npm`,
|
|
206
|
+
},
|
|
207
|
+
yarn: {
|
|
208
|
+
label: 'Yarn v1',
|
|
209
|
+
run: (script) => `npx -y yarn@1.22.22 ${script}`,
|
|
210
|
+
exec: (command) => `npx -y yarn@1.22.22 ${command}`,
|
|
211
|
+
install: 'npx -y yarn@1.22.22 install --frozen-lockfile --non-interactive',
|
|
212
|
+
setup: ` - uses: actions/setup-node@v4
|
|
213
|
+
with:
|
|
214
|
+
node-version: '20'
|
|
215
|
+
cache: yarn
|
|
216
|
+
cache-dependency-path: yarn.lock`,
|
|
217
|
+
},
|
|
218
|
+
pnpm: {
|
|
219
|
+
label: 'pnpm',
|
|
220
|
+
run: (script) => `npx -y pnpm ${script}`,
|
|
221
|
+
exec: (command) => `npx -y pnpm exec ${command}`,
|
|
222
|
+
install: 'npx -y pnpm install --frozen-lockfile',
|
|
223
|
+
setup: ` - uses: actions/setup-node@v4
|
|
224
|
+
with:
|
|
225
|
+
node-version: '20'
|
|
226
|
+
cache: pnpm
|
|
227
|
+
cache-dependency-path: pnpm-lock.yaml`,
|
|
228
|
+
},
|
|
229
|
+
bun: {
|
|
230
|
+
label: 'Bun',
|
|
231
|
+
run: (script) => `bun run ${script}`,
|
|
232
|
+
exec: (command) => `bunx ${command}`,
|
|
233
|
+
install: 'bun install --frozen-lockfile',
|
|
234
|
+
setup: ` - uses: actions/setup-node@v4
|
|
235
|
+
with:
|
|
236
|
+
node-version: '20'
|
|
237
|
+
- uses: oven-sh/setup-bun@v2`,
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
function detectPackageManager(root) {
|
|
242
|
+
if (fs.existsSync(path.join(root, 'bun.lock')) || fs.existsSync(path.join(root, 'bun.lockb'))) {
|
|
243
|
+
return PACKAGE_MANAGERS.bun;
|
|
244
|
+
}
|
|
245
|
+
if (fs.existsSync(path.join(root, 'pnpm-lock.yaml'))) return PACKAGE_MANAGERS.pnpm;
|
|
246
|
+
if (fs.existsSync(path.join(root, 'yarn.lock'))) return PACKAGE_MANAGERS.yarn;
|
|
247
|
+
return PACKAGE_MANAGERS.npm;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const PM = detectPackageManager(process.cwd());
|
|
251
|
+
|
|
201
252
|
const CONFIG = `import { defineConfig, devices } from '@playwright/test';
|
|
202
253
|
|
|
203
254
|
// Generated by styleproof-init.
|
|
@@ -225,7 +276,7 @@ export default defineConfig({
|
|
|
225
276
|
// accidentally capture a dev server. Adjust the command to your framework (e.g.
|
|
226
277
|
// \`vite build && vite preview\`); a server already running locally is reused.
|
|
227
278
|
webServer: {
|
|
228
|
-
command: '
|
|
279
|
+
command: '${PM.run('build')} && ${PM.run('start')}',
|
|
229
280
|
url: process.env.BASE_URL || '${baseUrl}',
|
|
230
281
|
reuseExistingServer: !process.env.CI,
|
|
231
282
|
timeout: 600_000, // a cold production build can take a few minutes
|
|
@@ -256,8 +307,32 @@ remote="$1"
|
|
|
256
307
|
# hook a second time — skip the expensive capture on that pass.
|
|
257
308
|
[ "\${STYLEPROOF_SKIP_CAPTURE:-}" = "1" ] && exit 0
|
|
258
309
|
|
|
259
|
-
|
|
260
|
-
|
|
310
|
+
${PM.exec(`styleproof-map --spec ${specPath}`)}
|
|
311
|
+
|
|
312
|
+
if git cat-file -e HEAD:stylemaps/current 2>/dev/null; then
|
|
313
|
+
tmp="\${TMPDIR:-/tmp}/styleproof-pre-push-diff.$$"
|
|
314
|
+
set +e
|
|
315
|
+
${PM.exec('styleproof-diff --base-ref HEAD --max=20')} > "$tmp"
|
|
316
|
+
code=$?
|
|
317
|
+
set -e
|
|
318
|
+
|
|
319
|
+
if [ "$code" -eq 2 ]; then
|
|
320
|
+
cat "$tmp" >&2
|
|
321
|
+
rm -f "$tmp"
|
|
322
|
+
exit 2
|
|
323
|
+
fi
|
|
324
|
+
|
|
325
|
+
if [ "$code" -eq 0 ]; then
|
|
326
|
+
rm -f "$tmp"
|
|
327
|
+
git restore --source=HEAD -- stylemaps
|
|
328
|
+
git clean -fdq -- stylemaps/current
|
|
329
|
+
exit 0
|
|
330
|
+
fi
|
|
331
|
+
|
|
332
|
+
cat "$tmp" >&2
|
|
333
|
+
rm -f "$tmp"
|
|
334
|
+
echo "StyleProof: style map changed. If this was not an intended visual change, pin live states or replay/fixture the data boundary before pushing." >&2
|
|
335
|
+
fi
|
|
261
336
|
|
|
262
337
|
git add stylemaps
|
|
263
338
|
git diff --cached --quiet -- stylemaps && exit 0 # map unchanged — let the push proceed
|
|
@@ -283,6 +358,8 @@ on: pull_request
|
|
|
283
358
|
|
|
284
359
|
permissions:
|
|
285
360
|
contents: read
|
|
361
|
+
issues: write
|
|
362
|
+
pull-requests: write
|
|
286
363
|
|
|
287
364
|
jobs:
|
|
288
365
|
styleproof:
|
|
@@ -291,12 +368,54 @@ jobs:
|
|
|
291
368
|
- uses: actions/checkout@v4
|
|
292
369
|
with:
|
|
293
370
|
fetch-depth: 0 # need the base ref to read its committed map
|
|
294
|
-
|
|
371
|
+
${PM.setup}
|
|
372
|
+
- run: ${PM.install}
|
|
373
|
+
- id: diff
|
|
374
|
+
name: Diff this branch's committed map against the base
|
|
375
|
+
shell: bash
|
|
376
|
+
run: |
|
|
377
|
+
set +e
|
|
378
|
+
${PM.exec(`styleproof-diff --base-ref "origin/\${{ github.event.pull_request.base.ref }}"`)} | tee styleproof-diff.txt
|
|
379
|
+
code=\${PIPESTATUS[0]}
|
|
380
|
+
set -e
|
|
381
|
+
echo "exit-code=\${code}" >> "$GITHUB_OUTPUT"
|
|
382
|
+
if [ "$code" -eq 0 ]; then
|
|
383
|
+
echo "result=clean" >> "$GITHUB_OUTPUT"
|
|
384
|
+
elif [ "$code" -eq 1 ] || [ "$code" -eq 3 ]; then
|
|
385
|
+
echo "result=changed" >> "$GITHUB_OUTPUT"
|
|
386
|
+
else
|
|
387
|
+
echo "result=error" >> "$GITHUB_OUTPUT"
|
|
388
|
+
fi
|
|
389
|
+
- name: Comment StyleProof result
|
|
390
|
+
if: >-
|
|
391
|
+
always() &&
|
|
392
|
+
github.event_name == 'pull_request' &&
|
|
393
|
+
steps.diff.outputs.result != '' &&
|
|
394
|
+
steps.diff.outputs.result != 'error'
|
|
395
|
+
uses: actions/github-script@v9
|
|
295
396
|
with:
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
397
|
+
script: |
|
|
398
|
+
const issue_number = context.payload.pull_request.number;
|
|
399
|
+
const marker = '<!-- styleproof-report -->';
|
|
400
|
+
const clean = '\${{ steps.diff.outputs.result }}' === 'clean';
|
|
401
|
+
const body = clean
|
|
402
|
+
? marker + '\\n## 🗺️ StyleProof report\\n\\n✓ No visual changes detected.'
|
|
403
|
+
: marker + '\\n## 🗺️ StyleProof report\\n\\nVisual changes detected. See the StyleProof check logs for the computed-style diff.';
|
|
404
|
+
const { data: comments } = await github.rest.issues.listComments({
|
|
405
|
+
...context.repo,
|
|
406
|
+
issue_number,
|
|
407
|
+
per_page: 100,
|
|
408
|
+
});
|
|
409
|
+
const existing = comments.find((comment) => comment.body && comment.body.includes(marker));
|
|
410
|
+
if (existing) {
|
|
411
|
+
await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body });
|
|
412
|
+
} else {
|
|
413
|
+
await github.rest.issues.createComment({ ...context.repo, issue_number, body });
|
|
414
|
+
}
|
|
415
|
+
- name: Fail on StyleProof diff
|
|
416
|
+
if: steps.diff.outputs.exit-code != '0'
|
|
417
|
+
shell: bash
|
|
418
|
+
run: exit \${{ steps.diff.outputs.exit-code }}
|
|
300
419
|
`;
|
|
301
420
|
|
|
302
421
|
function writeFileSafe(file, contents, { force: f } = {}) {
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Capture the current branch's computed-style map.
|
|
4
|
+
*
|
|
5
|
+
* The zero-config path is the committed-map flow scaffolded by styleproof-init:
|
|
6
|
+
* styleproof-map
|
|
7
|
+
*
|
|
8
|
+
* It runs Playwright against e2e/styleproof.spec.ts with:
|
|
9
|
+
* STYLEMAP_DIR=current
|
|
10
|
+
* STYLEPROOF_BASEDIR=stylemaps
|
|
11
|
+
* STYLEPROOF_SCREENSHOTS=0
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import {
|
|
16
|
+
isHelpArg,
|
|
17
|
+
missingSpecMessage,
|
|
18
|
+
playwrightMissingMessage,
|
|
19
|
+
showHelpAndExit,
|
|
20
|
+
unknownFlagMessage,
|
|
21
|
+
} from '../dist/cli-errors.js';
|
|
22
|
+
|
|
23
|
+
const HELP = `styleproof-map — capture this branch's computed-style map
|
|
24
|
+
|
|
25
|
+
usage: styleproof-map [options] [-- <playwright args>]
|
|
26
|
+
|
|
27
|
+
options:
|
|
28
|
+
--spec <path> StyleProof spec that must exist (default: e2e/styleproof.spec.ts)
|
|
29
|
+
--dir <label> output label under --base-dir (default: current)
|
|
30
|
+
--base-dir <path> output root directory (default: stylemaps)
|
|
31
|
+
--screenshots keep screenshots for reports (default: off for committed maps)
|
|
32
|
+
--no-screenshots write lean .json.gz maps only (default)
|
|
33
|
+
--keep-har keep recorded HAR files for advanced replay workflows
|
|
34
|
+
-h, --help show this help
|
|
35
|
+
|
|
36
|
+
Examples:
|
|
37
|
+
styleproof-map
|
|
38
|
+
styleproof-map --spec e2e/styleproof.spec.ts
|
|
39
|
+
styleproof-map --dir review --base-dir __stylemaps__ --screenshots --keep-har
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
const argv = process.argv.slice(2);
|
|
43
|
+
let spec = 'e2e/styleproof.spec.ts';
|
|
44
|
+
let dir = process.env.STYLEMAP_DIR ?? 'current';
|
|
45
|
+
let baseDir = process.env.STYLEPROOF_BASEDIR ?? 'stylemaps';
|
|
46
|
+
let screenshots = process.env.STYLEPROOF_SCREENSHOTS ?? '0';
|
|
47
|
+
let keepHar = process.env.STYLEPROOF_KEEP_HAR === '1';
|
|
48
|
+
const playwrightArgs = [];
|
|
49
|
+
|
|
50
|
+
for (let i = 0; i < argv.length; i++) {
|
|
51
|
+
const a = argv[i];
|
|
52
|
+
if (isHelpArg(a)) showHelpAndExit(HELP);
|
|
53
|
+
else if (a === '--') {
|
|
54
|
+
playwrightArgs.push(...argv.slice(i + 1));
|
|
55
|
+
break;
|
|
56
|
+
} else if (a === '--spec') spec = argv[++i];
|
|
57
|
+
else if (a.startsWith('--spec=')) spec = a.slice(7);
|
|
58
|
+
else if (a === '--dir') dir = argv[++i];
|
|
59
|
+
else if (a.startsWith('--dir=')) dir = a.slice(6);
|
|
60
|
+
else if (a === '--base-dir') baseDir = argv[++i];
|
|
61
|
+
else if (a.startsWith('--base-dir=')) baseDir = a.slice(11);
|
|
62
|
+
else if (a === '--screenshots') screenshots = '1';
|
|
63
|
+
else if (a === '--no-screenshots') screenshots = '0';
|
|
64
|
+
else if (a === '--keep-har') keepHar = true;
|
|
65
|
+
else if (a.startsWith('--')) {
|
|
66
|
+
console.error(unknownFlagMessage('styleproof-map', a));
|
|
67
|
+
process.exit(2);
|
|
68
|
+
} else {
|
|
69
|
+
spec = a;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!spec) {
|
|
74
|
+
console.error('--spec requires a path');
|
|
75
|
+
process.exit(2);
|
|
76
|
+
}
|
|
77
|
+
if (!dir) {
|
|
78
|
+
console.error('--dir requires a label');
|
|
79
|
+
process.exit(2);
|
|
80
|
+
}
|
|
81
|
+
if (!baseDir) {
|
|
82
|
+
console.error('--base-dir requires a path');
|
|
83
|
+
process.exit(2);
|
|
84
|
+
}
|
|
85
|
+
if (!fs.existsSync(spec)) {
|
|
86
|
+
console.error(missingSpecMessage(spec));
|
|
87
|
+
process.exit(2);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function removeHarFiles(root) {
|
|
91
|
+
if (!fs.existsSync(root)) return;
|
|
92
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
93
|
+
const full = `${root}/${entry.name}`;
|
|
94
|
+
if (entry.isDirectory()) removeHarFiles(full);
|
|
95
|
+
else if (entry.isFile() && entry.name.endsWith('.har')) fs.rmSync(full, { force: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const command = process.platform === 'win32' ? 'playwright.cmd' : 'playwright';
|
|
100
|
+
const env = {
|
|
101
|
+
...process.env,
|
|
102
|
+
STYLEMAP_DIR: dir,
|
|
103
|
+
STYLEPROOF_BASEDIR: baseDir,
|
|
104
|
+
STYLEPROOF_SCREENSHOTS: screenshots,
|
|
105
|
+
};
|
|
106
|
+
const result = spawnSync(command, ['test', '--grep', 'styleproof capture', ...playwrightArgs], {
|
|
107
|
+
stdio: 'inherit',
|
|
108
|
+
env,
|
|
109
|
+
});
|
|
110
|
+
if (result.error) {
|
|
111
|
+
console.error(playwrightMissingMessage(result.error.message));
|
|
112
|
+
process.exit(2);
|
|
113
|
+
}
|
|
114
|
+
const status = result.status ?? 1;
|
|
115
|
+
if (status === 0 && !keepHar) removeHarFiles(`${baseDir}/${dir}`);
|
|
116
|
+
process.exit(status);
|
|
@@ -10,18 +10,24 @@
|
|
|
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';
|
|
14
13
|
import { generateStyleMapReport } from '../dist/report.js';
|
|
15
|
-
import {
|
|
14
|
+
import { cleanupBaseRefCaptureDirs, resolveBaseRefCaptureDirs } from '../dist/cli-base-ref.js';
|
|
15
|
+
import { cliErrorMessage, isHelpArg, showHelpAndExit, unknownFlagMessage } from '../dist/cli-errors.js';
|
|
16
16
|
|
|
17
|
-
const
|
|
17
|
+
const COMMAND = 'styleproof-report';
|
|
18
|
+
const DEFAULT_MAPS_DIR = 'stylemaps/current';
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
const HELP = `${COMMAND} — reviewable before/after report from two captures
|
|
21
|
+
|
|
22
|
+
usage: ${COMMAND} [baseRef] [options]
|
|
23
|
+
${COMMAND} --base-ref <gitref> [mapsDir] [options]
|
|
24
|
+
${COMMAND} <beforeDir> <afterDir> [options]
|
|
21
25
|
|
|
22
26
|
options:
|
|
23
|
-
--base-ref <ref>
|
|
24
|
-
|
|
27
|
+
--base-ref <ref> report <mapsDir> as committed at <ref> (e.g. main)
|
|
28
|
+
against your working <mapsDir> — base from git, no recapture
|
|
29
|
+
--maps-dir <dir> committed map dir for the base-ref flow
|
|
30
|
+
(default: ${DEFAULT_MAPS_DIR})
|
|
25
31
|
--out <dir> output directory (default: styleproof-report)
|
|
26
32
|
--image-base-url <url> prefix for image URLs in report.md (default: relative)
|
|
27
33
|
--pad <px> padding around changed rects when cropping (default: 24)
|
|
@@ -53,12 +59,11 @@ let minHeight;
|
|
|
53
59
|
let includeLayoutNoise = false;
|
|
54
60
|
let includeContent = false;
|
|
55
61
|
let baseRef = null;
|
|
62
|
+
let mapsDir = DEFAULT_MAPS_DIR;
|
|
56
63
|
for (let i = 0; i < argv.length; i++) {
|
|
57
64
|
const a = argv[i];
|
|
58
|
-
if (a
|
|
59
|
-
|
|
60
|
-
process.exit(0);
|
|
61
|
-
} else if (a === '--out') flags.out = argv[++i];
|
|
65
|
+
if (isHelpArg(a)) showHelpAndExit(HELP);
|
|
66
|
+
else if (a === '--out') flags.out = argv[++i];
|
|
62
67
|
else if (a.startsWith('--out=')) flags.out = a.slice(6);
|
|
63
68
|
else if (a === '--image-base-url') flags.imageBaseUrl = argv[++i];
|
|
64
69
|
else if (a.startsWith('--image-base-url=')) flags.imageBaseUrl = a.slice(17);
|
|
@@ -78,27 +83,31 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
78
83
|
else if (a.startsWith('--include-content=')) includeContent = a.slice(18) !== 'false';
|
|
79
84
|
else if (a === '--base-ref') baseRef = argv[++i];
|
|
80
85
|
else if (a.startsWith('--base-ref=')) baseRef = a.slice(11);
|
|
86
|
+
else if (a === '--maps-dir') mapsDir = argv[++i];
|
|
87
|
+
else if (a.startsWith('--maps-dir=')) mapsDir = a.slice(11);
|
|
81
88
|
else if (a.startsWith('--')) {
|
|
82
|
-
console.error(
|
|
89
|
+
console.error(unknownFlagMessage(COMMAND, a));
|
|
83
90
|
process.exit(2);
|
|
84
91
|
} else args.push(a);
|
|
85
92
|
}
|
|
86
93
|
let beforeDir;
|
|
87
94
|
let afterDir;
|
|
88
|
-
let
|
|
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
|
-
}
|
|
95
|
+
let baseCapture = null;
|
|
96
|
+
if (baseRef || args.length <= 1) {
|
|
94
97
|
try {
|
|
95
|
-
|
|
98
|
+
baseCapture = resolveBaseRefCaptureDirs({
|
|
99
|
+
command: COMMAND,
|
|
100
|
+
baseRef,
|
|
101
|
+
mapsDir,
|
|
102
|
+
args,
|
|
103
|
+
usage: 'usage: styleproof-report --base-ref <gitref> [mapsDir] [--out <dir>] [options]',
|
|
104
|
+
});
|
|
96
105
|
} catch (e) {
|
|
97
|
-
console.error(
|
|
106
|
+
console.error(cliErrorMessage(e));
|
|
98
107
|
process.exit(2);
|
|
99
108
|
}
|
|
100
|
-
beforeDir =
|
|
101
|
-
afterDir =
|
|
109
|
+
beforeDir = baseCapture.beforeDir;
|
|
110
|
+
afterDir = baseCapture.afterDir;
|
|
102
111
|
} else {
|
|
103
112
|
if (args.length !== 2) {
|
|
104
113
|
console.error('usage: styleproof-report <beforeDir> <afterDir> --out <dir> [options] (--help for all options)');
|
|
@@ -143,7 +152,7 @@ try {
|
|
|
143
152
|
console.error(e.message);
|
|
144
153
|
process.exit(2);
|
|
145
154
|
} finally {
|
|
146
|
-
|
|
155
|
+
cleanupBaseRefCaptureDirs(baseCapture);
|
|
147
156
|
}
|
|
148
157
|
|
|
149
158
|
const newNote = result.newSurfaces ? ` (+${result.newSurfaces} new surface(s) with no baseline)` : '';
|
package/dist/capture.js
CHANGED
|
@@ -24,7 +24,26 @@ const FRAMEWORK_IGNORE = [
|
|
|
24
24
|
'template',
|
|
25
25
|
'next-route-announcer',
|
|
26
26
|
'[id="__next-route-announcer__"]',
|
|
27
|
+
'[data-styleproof-hover-sink]',
|
|
27
28
|
];
|
|
29
|
+
function installHoverSink() {
|
|
30
|
+
let sink = document.querySelector('[data-styleproof-hover-sink]');
|
|
31
|
+
if (!sink) {
|
|
32
|
+
sink = document.createElement('div');
|
|
33
|
+
sink.setAttribute('data-styleproof-hover-sink', '');
|
|
34
|
+
document.body.appendChild(sink);
|
|
35
|
+
}
|
|
36
|
+
sink.setAttribute('style', [
|
|
37
|
+
'position:fixed',
|
|
38
|
+
'left:0',
|
|
39
|
+
'top:0',
|
|
40
|
+
'width:1px',
|
|
41
|
+
'height:1px',
|
|
42
|
+
'z-index:2147483647',
|
|
43
|
+
'pointer-events:auto',
|
|
44
|
+
'opacity:0',
|
|
45
|
+
].join(';'));
|
|
46
|
+
}
|
|
28
47
|
/** True if `path` is one of `roots` or a structural descendant of one. Shared by
|
|
29
48
|
* the capture (excluding live regions) and the diff (skipping them). */
|
|
30
49
|
export function isUnder(path, roots) {
|
|
@@ -582,15 +601,17 @@ export async function captureStyleMap(page, options = {}) {
|
|
|
582
601
|
const stabilize = options.stabilize ?? true;
|
|
583
602
|
const captureText = options.captureText ?? false;
|
|
584
603
|
const captureComponent = options.captureComponent ?? false;
|
|
585
|
-
// Neutralise real focus the same way FREEZE_CSS neutralises motion:
|
|
586
|
-
//
|
|
587
|
-
// resting state. Real :focus is
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
//
|
|
604
|
+
// Neutralise real hover/focus the same way FREEZE_CSS neutralises motion: park
|
|
605
|
+
// the pointer over an ignored 1px sink and blur whatever element holds focus so
|
|
606
|
+
// every read below is the no-interaction resting state. Real :hover/:focus is
|
|
607
|
+
// nondeterministic across runs (the last Playwright action, autofocus, late
|
|
608
|
+
// hydration, a stray prior action) and contaminates BOTH layers — it bakes an
|
|
609
|
+
// interaction style into the resting map, AND it cancels that forced-state
|
|
610
|
+
// delta (forcing :focus on an already-focused element changes nothing). States
|
|
611
|
+
// are certified deterministically via CDP forcePseudoState below, never via
|
|
612
|
+
// whatever happened to be hovered or focused.
|
|
613
|
+
await page.evaluate(installHoverSink);
|
|
614
|
+
await page.mouse.move(0, 0);
|
|
594
615
|
await page.evaluate(() => document.activeElement?.blur?.());
|
|
595
616
|
// Inject the structural-path helper once so both capturePage and
|
|
596
617
|
// markInteractiveElements (each serialized into the page by page.evaluate, which
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface BaseRefCaptureDirs {
|
|
2
|
+
beforeDir: string;
|
|
3
|
+
afterDir: string;
|
|
4
|
+
baseRef: string;
|
|
5
|
+
mapsDir: string;
|
|
6
|
+
tmpBase: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function resolveBaseRefCaptureDirs(options: {
|
|
9
|
+
command: string;
|
|
10
|
+
baseRef: string | null;
|
|
11
|
+
mapsDir: string;
|
|
12
|
+
args: string[];
|
|
13
|
+
usage: string;
|
|
14
|
+
}): BaseRefCaptureDirs;
|
|
15
|
+
export declare function cleanupBaseRefCaptureDirs(captureDirs: BaseRefCaptureDirs | null): void;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { inferBaseRef, materializeRef, GitRefError } from './gitref.js';
|
|
3
|
+
import { baseInferenceMessage, baseMapsMessage, missingWorkingMapsMessage } from './cli-errors.js';
|
|
4
|
+
function errorMessage(error) {
|
|
5
|
+
return error instanceof Error ? error.message : String(error);
|
|
6
|
+
}
|
|
7
|
+
function exitUsage(usage) {
|
|
8
|
+
throw new Error(usage);
|
|
9
|
+
}
|
|
10
|
+
function inferBaseRefOrExit(command) {
|
|
11
|
+
try {
|
|
12
|
+
return inferBaseRef();
|
|
13
|
+
}
|
|
14
|
+
catch (e) {
|
|
15
|
+
throw new Error(e instanceof GitRefError ? baseInferenceMessage(command, e.message) : errorMessage(e), {
|
|
16
|
+
cause: e,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function selectBaseRef(options) {
|
|
21
|
+
const { command, args, usage } = options;
|
|
22
|
+
if (options.baseRef) {
|
|
23
|
+
if (args.length > 1)
|
|
24
|
+
exitUsage(usage);
|
|
25
|
+
return { baseRef: options.baseRef, mapsDir: args[0] ?? options.mapsDir };
|
|
26
|
+
}
|
|
27
|
+
if (args.length === 1)
|
|
28
|
+
return { baseRef: args[0], mapsDir: options.mapsDir };
|
|
29
|
+
return { baseRef: inferBaseRefOrExit(command), mapsDir: options.mapsDir };
|
|
30
|
+
}
|
|
31
|
+
function materializeBaseRefOrExit(command, baseRef, mapsDir) {
|
|
32
|
+
try {
|
|
33
|
+
return materializeRef(baseRef, mapsDir);
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
throw new Error(e instanceof GitRefError ? baseMapsMessage(command, e.message, baseRef, mapsDir) : errorMessage(e), {
|
|
37
|
+
cause: e,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function resolveBaseRefCaptureDirs(options) {
|
|
42
|
+
const { command } = options;
|
|
43
|
+
const { baseRef, mapsDir } = selectBaseRef(options);
|
|
44
|
+
if (!fs.existsSync(mapsDir)) {
|
|
45
|
+
throw new Error(missingWorkingMapsMessage(command, mapsDir));
|
|
46
|
+
}
|
|
47
|
+
const tmpBase = materializeBaseRefOrExit(command, baseRef, mapsDir);
|
|
48
|
+
return { beforeDir: tmpBase, afterDir: mapsDir, baseRef, mapsDir, tmpBase };
|
|
49
|
+
}
|
|
50
|
+
export function cleanupBaseRefCaptureDirs(captureDirs) {
|
|
51
|
+
if (captureDirs)
|
|
52
|
+
fs.rmSync(captureDirs.tmpBase, { recursive: true, force: true });
|
|
53
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare function isHelpArg(arg: string | undefined): boolean;
|
|
2
|
+
export declare function showHelpAndExit(help: string): never;
|
|
3
|
+
export declare function cliErrorMessage(error: unknown): string;
|
|
4
|
+
export declare function unknownFlagMessage(command: string, flag: string): string;
|
|
5
|
+
export declare function missingSpecMessage(spec: string): string;
|
|
6
|
+
export declare function playwrightMissingMessage(message: string): string;
|
|
7
|
+
export declare function missingWorkingMapsMessage(command: string, dir: string): string;
|
|
8
|
+
export declare function missingManualCaptureMessage(command: string, dir: string): string;
|
|
9
|
+
export declare function baseInferenceMessage(command: string, message: string): string;
|
|
10
|
+
export declare function baseMapsMessage(command: string, message: string, baseRef: string, mapsDir: string): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export function isHelpArg(arg) {
|
|
2
|
+
return arg === '-h' || arg === '--help';
|
|
3
|
+
}
|
|
4
|
+
export function showHelpAndExit(help) {
|
|
5
|
+
process.stdout.write(help);
|
|
6
|
+
process.exit(0);
|
|
7
|
+
}
|
|
8
|
+
export function cliErrorMessage(error) {
|
|
9
|
+
return error instanceof Error ? error.message : String(error);
|
|
10
|
+
}
|
|
11
|
+
export function unknownFlagMessage(command, flag) {
|
|
12
|
+
return `${command}: unknown flag: ${flag}\nNext: run ${command} --help to see supported options.`;
|
|
13
|
+
}
|
|
14
|
+
export function missingSpecMessage(spec) {
|
|
15
|
+
return [
|
|
16
|
+
`styleproof-map: no StyleProof spec at ${spec}`,
|
|
17
|
+
'Next: run styleproof-init to scaffold the spec, or pass --spec <path> if your capture spec lives elsewhere.',
|
|
18
|
+
].join('\n');
|
|
19
|
+
}
|
|
20
|
+
export function playwrightMissingMessage(message) {
|
|
21
|
+
return [
|
|
22
|
+
`styleproof-map: could not run Playwright (${message})`,
|
|
23
|
+
'Next: install @playwright/test, then run npx playwright install chromium.',
|
|
24
|
+
].join('\n');
|
|
25
|
+
}
|
|
26
|
+
export function missingWorkingMapsMessage(command, dir) {
|
|
27
|
+
return [
|
|
28
|
+
`${command}: no capture at ${dir}`,
|
|
29
|
+
`Next: run styleproof-map to create ${dir}, then commit the updated maps.`,
|
|
30
|
+
'If your maps live elsewhere, pass --maps-dir <dir>.',
|
|
31
|
+
].join('\n');
|
|
32
|
+
}
|
|
33
|
+
export function missingManualCaptureMessage(command, dir) {
|
|
34
|
+
return [
|
|
35
|
+
`${command}: no capture at ${dir}`,
|
|
36
|
+
`Next: pass existing capture directories, or run styleproof-map first and use ${command} [baseRef].`,
|
|
37
|
+
].join('\n');
|
|
38
|
+
}
|
|
39
|
+
export function baseInferenceMessage(command, message) {
|
|
40
|
+
return [
|
|
41
|
+
`${command}: ${message}`,
|
|
42
|
+
`Next: pass a base ref explicitly, e.g. ${command} main, or set git config branch.<name>.gh-merge-base main.`,
|
|
43
|
+
].join('\n');
|
|
44
|
+
}
|
|
45
|
+
export function baseMapsMessage(command, message, baseRef, mapsDir) {
|
|
46
|
+
return [
|
|
47
|
+
`${command}: ${message}`,
|
|
48
|
+
`Next: make sure ${baseRef} contains committed captures at ${mapsDir}. On the base branch, run styleproof-map and commit ${mapsDir}.`,
|
|
49
|
+
].join('\n');
|
|
50
|
+
}
|
package/dist/diff.d.ts
CHANGED
|
@@ -61,6 +61,7 @@ export declare function diffStyleMapDirs(dirA: string, dirB: string): {
|
|
|
61
61
|
surfaces: SurfaceDiff[];
|
|
62
62
|
counts: DiffCounts;
|
|
63
63
|
volatile: number;
|
|
64
|
+
compared: number;
|
|
64
65
|
};
|
|
65
66
|
export declare function diffContentMaps(a: StyleMap, b: StyleMap): ContentChange[];
|
|
66
67
|
/** Per-surface content diff across two capture dirs (opt-in layer). Mirrors
|
package/dist/diff.js
CHANGED
|
@@ -13,6 +13,20 @@ function diffProps(propsA, propsB, fallbackA, fallbackB, unsetA, unsetB) {
|
|
|
13
13
|
}
|
|
14
14
|
return changed;
|
|
15
15
|
}
|
|
16
|
+
const LAYOUT_EQUIVALENT_MARGIN_PROPS = new Set([
|
|
17
|
+
'margin-left',
|
|
18
|
+
'margin-right',
|
|
19
|
+
'margin-inline-start',
|
|
20
|
+
'margin-inline-end',
|
|
21
|
+
]);
|
|
22
|
+
function sameRect(a, b) {
|
|
23
|
+
return !!a && !!b && a.every((v, i) => v === b[i]);
|
|
24
|
+
}
|
|
25
|
+
function dropLayoutEquivalentMarginProps(props, a, b) {
|
|
26
|
+
if (!sameRect(a?.rect, b?.rect))
|
|
27
|
+
return props;
|
|
28
|
+
return props.filter((p) => !LAYOUT_EQUIVALENT_MARGIN_PROPS.has(p.prop));
|
|
29
|
+
}
|
|
16
30
|
/** Union of both captures' live-region paths — skipped by every diff layer so a
|
|
17
31
|
* region volatile on either side never reads as a change. */
|
|
18
32
|
function volatilePaths(a, b) {
|
|
@@ -80,7 +94,8 @@ export function diffStyleMaps(a, b) {
|
|
|
80
94
|
// tag defaults for maps written before that fix.
|
|
81
95
|
const pdefsA = pseudo ? (a.defaults[ea.tag + pseudo] ?? defsA) : defsA;
|
|
82
96
|
const pdefsB = pseudo ? (b.defaults[eb.tag + pseudo] ?? defsB) : defsB;
|
|
83
|
-
const
|
|
97
|
+
const rawProps = diffProps(propsA, propsB, pdefsA, pdefsB, '(unset)', '(unset)');
|
|
98
|
+
const props = pseudo ? rawProps : dropLayoutEquivalentMarginProps(rawProps, ea, eb);
|
|
84
99
|
if (props.length)
|
|
85
100
|
findings.push({ kind: 'style', path: p, cls: ea.cls, pseudo, props });
|
|
86
101
|
}
|
|
@@ -115,8 +130,9 @@ export function diffStyleMaps(a, b) {
|
|
|
115
130
|
const db = sb[state] ?? {};
|
|
116
131
|
for (const sub of new Set([...Object.keys(da), ...Object.keys(db)])) {
|
|
117
132
|
const props = diffProps(da[sub] ?? {}, db[sub] ?? {}, {}, {}, '(state does not change it)', '(state no longer changes it)');
|
|
118
|
-
|
|
119
|
-
|
|
133
|
+
const filtered = dropLayoutEquivalentMarginProps(props, a.elements[sub], b.elements[sub]);
|
|
134
|
+
if (filtered.length)
|
|
135
|
+
findings.push({ kind: 'state', path: p, cls, state, sub, props: filtered });
|
|
120
136
|
}
|
|
121
137
|
}
|
|
122
138
|
}
|
|
@@ -167,7 +183,7 @@ export function diffStyleMapDirs(dirA, dirB) {
|
|
|
167
183
|
if (findings.length)
|
|
168
184
|
surfaces.push({ surface, findings });
|
|
169
185
|
}
|
|
170
|
-
return { surfaces, counts, volatile };
|
|
186
|
+
return { surfaces, counts, volatile, compared: names.length };
|
|
171
187
|
}
|
|
172
188
|
/**
|
|
173
189
|
* Diff the OPT-IN content layer: elements whose own rendered text changed.
|
package/dist/gitref.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** A readable failure materialising a base from git — CLIs map this to exit 2. */
|
|
2
2
|
export declare class GitRefError extends Error {
|
|
3
3
|
}
|
|
4
|
+
/** Infer the committed-map base ref for local and GitHub Actions CLI runs. */
|
|
5
|
+
export declare function inferBaseRef(env?: NodeJS.ProcessEnv): string;
|
|
4
6
|
/**
|
|
5
7
|
* Materialise the captures committed at `dir` as of `ref` into a fresh temp dir,
|
|
6
8
|
* so a diff/report can use e.g. `main`'s committed map as the base with no checkout
|
package/dist/gitref.js
CHANGED
|
@@ -5,6 +5,50 @@ import path from 'node:path';
|
|
|
5
5
|
/** A readable failure materialising a base from git — CLIs map this to exit 2. */
|
|
6
6
|
export class GitRefError extends Error {
|
|
7
7
|
}
|
|
8
|
+
function git(args) {
|
|
9
|
+
return spawnSync('git', args, { encoding: 'utf8', maxBuffer: 1 << 28 });
|
|
10
|
+
}
|
|
11
|
+
function gitOutput(args) {
|
|
12
|
+
const r = git(args);
|
|
13
|
+
return r.status === 0 ? r.stdout.trim() : '';
|
|
14
|
+
}
|
|
15
|
+
function refExists(ref) {
|
|
16
|
+
return git(['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]).status === 0;
|
|
17
|
+
}
|
|
18
|
+
function firstExistingRef(refs) {
|
|
19
|
+
return refs.find(refExists);
|
|
20
|
+
}
|
|
21
|
+
function baseRefCandidate(ref) {
|
|
22
|
+
const refs = ref.startsWith('origin/') || ref.startsWith('refs/') ? [ref] : [`origin/${ref}`, ref];
|
|
23
|
+
return firstExistingRef(refs) ?? refs[0];
|
|
24
|
+
}
|
|
25
|
+
function ghPrBaseRef() {
|
|
26
|
+
const r = spawnSync('gh', ['pr', 'view', '--json', 'baseRefName', '--jq', '.baseRefName'], {
|
|
27
|
+
encoding: 'utf8',
|
|
28
|
+
maxBuffer: 1 << 20,
|
|
29
|
+
});
|
|
30
|
+
return r.status === 0 ? r.stdout.trim() : '';
|
|
31
|
+
}
|
|
32
|
+
/** Infer the committed-map base ref for local and GitHub Actions CLI runs. */
|
|
33
|
+
export function inferBaseRef(env = process.env) {
|
|
34
|
+
if (env.GITHUB_BASE_REF) {
|
|
35
|
+
return baseRefCandidate(env.GITHUB_BASE_REF);
|
|
36
|
+
}
|
|
37
|
+
const branch = gitOutput(['branch', '--show-current']);
|
|
38
|
+
if (branch) {
|
|
39
|
+
const configured = gitOutput(['config', `branch.${branch}.gh-merge-base`]);
|
|
40
|
+
if (configured) {
|
|
41
|
+
return baseRefCandidate(configured);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const prBase = ghPrBaseRef();
|
|
45
|
+
if (prBase)
|
|
46
|
+
return baseRefCandidate(prBase);
|
|
47
|
+
const fallback = firstExistingRef(['origin/main', 'origin/master', 'main', 'master']);
|
|
48
|
+
if (fallback)
|
|
49
|
+
return fallback;
|
|
50
|
+
throw new GitRefError('could not infer a base branch (tried GITHUB_BASE_REF, branch.<name>.gh-merge-base, gh pr view, origin/main, origin/master, main, master)');
|
|
51
|
+
}
|
|
8
52
|
/**
|
|
9
53
|
* Materialise the captures committed at `dir` as of `ref` into a fresh temp dir,
|
|
10
54
|
* so a diff/report can use e.g. `main`'s committed map as the base with no checkout
|
|
@@ -15,7 +59,7 @@ export class GitRefError extends Error {
|
|
|
15
59
|
* Throws {@link GitRefError} (never exits) so it's reusable from any entry point.
|
|
16
60
|
*/
|
|
17
61
|
export function materializeRef(ref, dir) {
|
|
18
|
-
const top =
|
|
62
|
+
const top = git(['rev-parse', '--show-toplevel']);
|
|
19
63
|
if (top.status !== 0)
|
|
20
64
|
throw new GitRefError('must run inside a git repository');
|
|
21
65
|
const toplevel = top.stdout.trim();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.1",
|
|
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",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
"bin": {
|
|
39
39
|
"styleproof-init": "./bin/styleproof-init.mjs",
|
|
40
|
+
"styleproof-map": "./bin/styleproof-map.mjs",
|
|
40
41
|
"styleproof-diff": "./bin/styleproof-diff.mjs",
|
|
41
42
|
"styleproof-report": "./bin/styleproof-report.mjs"
|
|
42
43
|
},
|