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 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
@@ -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
- tmpBase = materializeRef(baseRef, args[0]);
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 {
@@ -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
- if (args.length !== 2) {
79
- console.error('usage: styleproof-report <beforeDir> <afterDir> --out <dir> [options] (--help for all options)');
80
- process.exit(2);
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: args[0],
103
- afterDir: args[1],
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,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.4.0",
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 test/smoke.e2e.spec.ts",
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"