styleproof 2.4.0 → 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 +37 -0
- package/bin/styleproof-diff.mjs +7 -48
- package/bin/styleproof-init.mjs +8 -0
- package/bin/styleproof-report.mjs +35 -5
- package/dist/gitref.d.ts +13 -0
- package/dist/gitref.js +44 -0
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,43 @@ 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
|
+
|
|
10
47
|
## [2.4.0] - 2026-06-26
|
|
11
48
|
|
|
12
49
|
### Added
|
package/bin/styleproof-diff.mjs
CHANGED
|
@@ -26,10 +26,8 @@
|
|
|
26
26
|
* error, 3 = only new surfaces (present on one side, no baseline to diff against).
|
|
27
27
|
*/
|
|
28
28
|
import fs from 'node:fs';
|
|
29
|
-
import os from 'node:os';
|
|
30
|
-
import path from 'node:path';
|
|
31
|
-
import { spawnSync } from 'node:child_process';
|
|
32
29
|
import { diffStyleMapDirs, findingLabel } from '../dist/diff.js';
|
|
30
|
+
import { materializeRef, GitRefError } from '../dist/gitref.js';
|
|
33
31
|
|
|
34
32
|
const HELP = `styleproof-diff — certify a CSS refactor by diffing two computed-style captures
|
|
35
33
|
|
|
@@ -68,50 +66,6 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
68
66
|
} else args.push(argv[i]);
|
|
69
67
|
}
|
|
70
68
|
|
|
71
|
-
/** Run git, exiting 2 with a clean message on failure. */
|
|
72
|
-
function git(gitArgs, { encoding = 'buffer' } = {}) {
|
|
73
|
-
const r = spawnSync('git', gitArgs, { encoding, maxBuffer: 1 << 28 });
|
|
74
|
-
return r;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Materialise the captures committed at <dir> as of <ref> into a fresh temp dir,
|
|
79
|
-
* so the base of the diff is e.g. main's map — no checkout, no recapture. Reads
|
|
80
|
-
* only via git (no `tar`/deps); binary `.json.gz` files come through `git show`.
|
|
81
|
-
*/
|
|
82
|
-
function materializeRef(ref, dir) {
|
|
83
|
-
const top = git(['rev-parse', '--show-toplevel'], { encoding: 'utf8' });
|
|
84
|
-
if (top.status !== 0) {
|
|
85
|
-
console.error('styleproof-diff --base-ref must run inside a git repository');
|
|
86
|
-
process.exit(2);
|
|
87
|
-
}
|
|
88
|
-
const toplevel = top.stdout.trim();
|
|
89
|
-
// -z: NUL-separated, unquoted paths (handles spaces/unicode).
|
|
90
|
-
const ls = git(['ls-tree', '-r', '-z', '--name-only', ref, '--', dir], { encoding: 'utf8' });
|
|
91
|
-
if (ls.status !== 0) {
|
|
92
|
-
console.error(`cannot read ${dir} at ${ref}: ${(ls.stderr || '').toString().trim()}`);
|
|
93
|
-
process.exit(2);
|
|
94
|
-
}
|
|
95
|
-
const files = ls.stdout.split('\0').filter(Boolean);
|
|
96
|
-
if (files.length === 0) {
|
|
97
|
-
console.error(`no committed captures at ${dir} in ${ref} — commit the maps so the base lives in git`);
|
|
98
|
-
process.exit(2);
|
|
99
|
-
}
|
|
100
|
-
const dirRepoRel = path.relative(toplevel, path.resolve(dir));
|
|
101
|
-
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'styleproof-baseref-'));
|
|
102
|
-
for (const f of files) {
|
|
103
|
-
const show = git(['show', `${ref}:${f}`]); // buffer — preserves .json.gz bytes
|
|
104
|
-
if (show.status !== 0) {
|
|
105
|
-
console.error(`cannot read ${f} at ${ref}`);
|
|
106
|
-
process.exit(2);
|
|
107
|
-
}
|
|
108
|
-
const out = path.join(dest, path.relative(dirRepoRel, f));
|
|
109
|
-
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
110
|
-
fs.writeFileSync(out, show.stdout);
|
|
111
|
-
}
|
|
112
|
-
return dest;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
69
|
let dirA;
|
|
116
70
|
let dirB;
|
|
117
71
|
let tmpBase = null;
|
|
@@ -124,7 +78,12 @@ if (baseRef) {
|
|
|
124
78
|
console.error(`no capture at ${args[0]}`);
|
|
125
79
|
process.exit(2);
|
|
126
80
|
}
|
|
127
|
-
|
|
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
|
+
}
|
|
128
87
|
dirA = tmpBase;
|
|
129
88
|
dirB = args[0];
|
|
130
89
|
} else {
|
package/bin/styleproof-init.mjs
CHANGED
|
@@ -209,6 +209,14 @@ const CONFIG = `import { defineConfig, devices } from '@playwright/test';
|
|
|
209
209
|
// in-flight data either way, but a production build removes the variance at the source.)
|
|
210
210
|
export default defineConfig({
|
|
211
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,
|
|
212
220
|
use: {
|
|
213
221
|
baseURL: process.env.BASE_URL || '${baseUrl}',
|
|
214
222
|
},
|
|
@@ -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)` : '';
|
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/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"
|