styleproof 3.1.5 → 3.2.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,22 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.2.0] - 2026-06-30
11
+
12
+ ### Added
13
+
14
+ - Added optional `styleproof-map --crawl-base-url ... --crawl-route ...` pre-map
15
+ variant crawling, so automation can refresh the generated variant manifest
16
+ immediately before map capture.
17
+
18
+ ### Changed
19
+
20
+ - `styleproof-init` now writes a dedicated `playwright.styleproof.config.ts`,
21
+ scopes discovery to the StyleProof spec, detects Vite/Next production preview
22
+ commands, and respects pnpm/Corepack pins in generated commands.
23
+ - Map upload dirty-tree detection now ignores Next's generated `next-env.d.ts`
24
+ shim, while normal source edits still block upload.
25
+
10
26
  ## [3.1.5] - 2026-06-29
11
27
 
12
28
  ### Added
@@ -1058,7 +1074,8 @@ number)`), so each viewport band can capture at its own height. Default remains
1058
1074
  - `styleproof-diff` CLI: certifies a refactor (exit 0) or names the exact element,
1059
1075
  property, and state that drifted (exit 1).
1060
1076
 
1061
- [Unreleased]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.5...HEAD
1077
+ [Unreleased]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.2.0...HEAD
1078
+ [3.2.0]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.5...v3.2.0
1062
1079
  [3.1.5]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.4...v3.1.5
1063
1080
  [3.1.4]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.3...v3.1.4
1064
1081
  [3.1.3]: https://github.com/BenSheridanEdwards/StyleProof/compare/v3.1.2...v3.1.3
package/README.md CHANGED
@@ -16,7 +16,7 @@ report. Intentional visual changes get approved; unexpected ones block or fail.
16
16
  - [What it catches](#what-it-catches)
17
17
  - [What you own](#what-you-own)
18
18
  - [What the PR gets](#what-the-pr-gets)
19
- - [Don't let a new page ship uncaptured](#dont-let-a-new-page-ship-uncaptured)
19
+ - [Auto-discovery](#auto-discovery)
20
20
  - [What a report looks like](#what-a-report-looks-like)
21
21
  - [Works with any styling system](#works-with-any-styling-system)
22
22
  - [Breakpoints, detected automatically](#breakpoints-detected-automatically)
@@ -58,7 +58,7 @@ silently passing.
58
58
 
59
59
  1. A **surface** is one UI state to certify: a route, tab, modal-open state,
60
60
  dropdown-open state, toast-visible state, loading state, etc.
61
- 2. You list surfaces in a Playwright-style spec.
61
+ 2. You list or auto-discover surfaces in a Playwright-style spec.
62
62
  3. StyleProof opens each surface at real breakpoint widths and records computed
63
63
  styles for every captured element.
64
64
  4. On a PR, it compares base vs head and reports exactly which rendered styles
@@ -94,6 +94,12 @@ states that matter:
94
94
  - required-but-not-yet-captured states belong in `expected`, where the coverage
95
95
  guard fails until they are captured or explicitly excluded with a reason.
96
96
 
97
+ Auto-discovery is a key feature, not a side path: StyleProof can discover
98
+ Next.js routes, crawl real nav links, inventory component files, harvest
99
+ one-step variants, click safe popups, detect breakpoints, and flag live or
100
+ volatile regions. You review the app-specific leftovers; StyleProof keeps the
101
+ mechanical inventory from drifting.
102
+
97
103
  That boundary is deliberate. StyleProof should not guess destructive flows,
98
104
  auth-only fixtures, or which product state your component needs. It should make
99
105
  missing coverage loud.
@@ -114,9 +120,19 @@ status green for that commit. Clean runs still leave a receipt: `No visual
114
120
  changes detected.` New surfaces are shown as new baselines; coverage gaps are
115
121
  handled by `expected`.
116
122
 
117
- ## Don't let a new page ship uncaptured
123
+ ## Auto-discovery
124
+
125
+ StyleProof's auto-discovery keeps the boring inventory out of your hands where it
126
+ can be inferred safely: Next.js routes, crawlable links, component files,
127
+ semantic popups, one-step variants, breakpoints, and volatile/live candidates.
128
+ The key loop is simple: discover what the app exposes, capture what is safe, and
129
+ fail loudly for anything that still needs an owner.
118
130
 
119
- StyleProof diffs the surfaces your spec lists — so a page nobody added to the list is invisible to the gate. Its change has no base capture _and_ no head capture, so it never appears in any diff, and the status goes green having never looked at it. This is the one thing the captures can't catch on their own: a capture that was never taken.
131
+ StyleProof diffs the surfaces your spec lists or discovers — so a page nobody
132
+ added to either set is invisible to the gate. Its change has no base capture
133
+ _and_ no head capture, so it never appears in any diff, and the status goes
134
+ green having never looked at it. This is the one thing the captures can't catch
135
+ on their own: a capture that was never taken.
120
136
 
121
137
  Declare your app's route/view universe in `expected` and StyleProof emits a coverage-guard test in your **normal** suite (it runs even without `STYLEMAP_DIR` — it's a static check, no browser). It fails the moment a route exists with no surface, so a new page can't ship uncaptured:
122
138
 
@@ -258,9 +274,17 @@ styles. It also reports live-state candidates that need fixtures or opt-outs.
258
274
  styleproof-variants --base-url http://localhost:3000 --route / --route settings=/settings
259
275
  ```
260
276
 
261
- Use it as a manifest generator, not a replacement for review. The generated JSON
262
- is deliberately not read by `styleproof-map`; approve the states you want, then
263
- copy them into your capture spec as ordinary `variants` / `liveStates`.
277
+ Use it as a manifest generator, not a replacement for review. To refresh that
278
+ manifest as part of the map loop, pass the same crawl inputs to `styleproof-map`;
279
+ it runs the crawler before Playwright captures the maps:
280
+
281
+ ```bash
282
+ styleproof-map --crawl-base-url http://localhost:3000 --crawl-route / --crawl-route settings=/settings
283
+ ```
284
+
285
+ The app must already be reachable at `--crawl-base-url`. If Playwright's
286
+ `webServer` is the thing starting the app, keep route-link crawling inside the
287
+ capture run with `defineCrawlCapture`.
264
288
 
265
289
  ```json
266
290
  {
@@ -393,7 +417,7 @@ npx styleproof-init
393
417
  It scaffolds:
394
418
 
395
419
  - 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);
396
- - a **`playwright.config.ts`** that builds and serves a **production build** (never a flaky dev server) and captures surfaces **in parallel** (`fullyParallel`);
420
+ - a dedicated **`playwright.styleproof.config.ts`** that builds and serves a **production build** (never a flaky dev server), scopes discovery to the StyleProof spec, and captures surfaces **in parallel** (`fullyParallel`) without disturbing your app's existing Playwright config;
397
421
  - `.gitignore` entries for `.styleproof/`, `test-results/`, and `playwright-report/`;
398
422
  - a **cache-first CI workflow** that restores reusable maps from the `styleproof-maps` branch and generates the report without a browser when both maps are already built.
399
423
 
@@ -637,21 +661,24 @@ Non-visual and framework-injected elements (`<meta>`/`<title>`/`<script>`/`<styl
637
661
 
638
662
  **Capture env vars** (wire CI without editing the spec):
639
663
 
640
- | Env | Purpose |
641
- | ------------------------- | --------------------------------------------------------------------------------------------------------------------- |
642
- | `STYLEMAP_DIR` | Output label; the capture is skipped entirely when unset. |
643
- | `STYLEPROOF_BASEDIR` | Output root dir (runner default `__stylemaps__`; `styleproof-map` CLI default `.styleproof/maps`). |
644
- | `STYLEPROOF_SCREENSHOTS` | `0` to skip full-page screenshots. The CLI keeps screenshots by default so reports can crop maps restored from cache. |
645
- | `STYLEPROOF_REPLAY_FROM` | Baseline dir to replay recorded data from — set this on the **head** capture. |
646
- | `STYLEPROOF_REPLAY_URL` | Override the `**/api/**` data-boundary glob. |
647
- | `STYLEPROOF_SELFCHECK` | `1` to capture each surface twice and fail if the two differ. |
648
- | `STYLEPROOF_UPLOAD` | `1` to require map-store upload; `0` to capture locally only. |
649
- | `STYLEPROOF_CACHE_BRANCH` | Map store branch (default `styleproof-maps`). |
664
+ | Env | Purpose |
665
+ | --------------------------- | --------------------------------------------------------------------------------------------------------------------- |
666
+ | `STYLEMAP_DIR` | Output label; the capture is skipped entirely when unset. |
667
+ | `STYLEPROOF_BASEDIR` | Output root dir (runner default `__stylemaps__`; `styleproof-map` CLI default `.styleproof/maps`). |
668
+ | `STYLEPROOF_SCREENSHOTS` | `0` to skip full-page screenshots. The CLI keeps screenshots by default so reports can crop maps restored from cache. |
669
+ | `STYLEPROOF_REPLAY_FROM` | Baseline dir to replay recorded data from — set this on the **head** capture. |
670
+ | `STYLEPROOF_REPLAY_URL` | Override the `**/api/**` data-boundary glob. |
671
+ | `STYLEPROOF_SELFCHECK` | `1` to capture each surface twice and fail if the two differ. |
672
+ | `STYLEPROOF_UPLOAD` | `1` to require map-store upload; `0` to capture locally only. |
673
+ | `STYLEPROOF_CACHE_BRANCH` | Map store branch (default `styleproof-maps`). |
674
+ | `STYLEPROOF_CRAWL_BASE_URL` | App URL for the optional pre-map `styleproof-variants` crawl. |
675
+ | `STYLEPROOF_CRAWL_ROUTES` | Comma-separated routes for the optional pre-map crawl, e.g. `/,settings=/settings`. |
676
+ | `STYLEPROOF_CRAWL_STRICT` | `1` to fail the optional pre-map crawl on live-state fixtures or skipped candidates. |
650
677
 
651
678
  **CLIs** (every flag accepts `--flag value` and `--flag=value`; `--help` lists all):
652
679
 
653
- - `styleproof-init` — scaffold the gate: the capture spec, a `playwright.config.ts` (production-build `webServer`, parallel capture), `.gitignore` cache entries, and the cache-first report 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.
654
- - `styleproof-map` — capture the current commit's computed-style map through Playwright. By default it writes `.styleproof/maps/current`, keeps screenshots for reports, writes a manifest, and uploads to `styleproof-maps` outside CI when the working tree was clean and a git remote exists. Pass `--no-upload`, `--restore --sha <commit>`, `--spec`, `--dir`, `--base-dir`, or `--no-screenshots` for custom flows.
680
+ - `styleproof-init` — scaffold the gate: the capture spec, a dedicated `playwright.styleproof.config.ts` (production-build `webServer`, parallel capture), `.gitignore` cache entries, and the cache-first report workflow. One command. Generated commands follow the repo's lockfile (`bun.lock`/`bun.lockb`, `pnpm-lock.yaml`, `yarn.lock`, or npm by default), respect pnpm/Corepack version pins, and detect Vite/Next production preview commands instead of assuming every repo has `start`.
681
+ - `styleproof-map` — capture the current commit's computed-style map through Playwright. By default it writes `.styleproof/maps/current`, keeps screenshots for reports, writes a manifest, and uploads to `styleproof-maps` outside CI when the working tree was clean and a git remote exists. Pass `--crawl-base-url` plus repeated `--crawl-route` to run `styleproof-variants` before capture, `--no-upload`, `--restore --sha <commit>`, `--spec`, `--dir`, `--base-dir`, or `--no-screenshots` for custom flows.
655
682
  - `styleproof-diff` — the certify gate. With no args, it restores cached maps for the current commit and inferred base (`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 for CI fallback captures. 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`.
656
683
  - `styleproof-report` — render the diff to a Markdown report with before/after crops. With no args, it reports cached maps for the current commit against the inferred base; `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).
657
684
  - `styleproof-variants` — crawl a running app for one-step state variants and write `styleproof.variants.generated.json`. Pass `--base-url`, repeat `--route`, and use `--strict` when unresolved skipped/live candidates should fail automation.
@@ -11,14 +11,15 @@
11
11
  * the `expected` coverage guard from the app's routes at run time, so a page
12
12
  * added later can't ship without a surface; otherwise it writes one sample
13
13
  * surface plus a commented guard block to wire to your own route registry.
14
- * - playwright.config.ts: only if one does not already exist, so an existing
15
- * Playwright project is never disturbed.
14
+ * - playwright.styleproof.config.ts: a dedicated production-build Playwright
15
+ * config for StyleProof captures, so an existing app Playwright config is
16
+ * never disturbed or accidentally reused.
16
17
  * - .github/workflows/styleproof.yml: restores reusable maps from the
17
18
  * styleproof-maps branch and only captures in CI when the maps are missing.
18
19
  *
19
20
  * Idempotent: re-running never overwrites an existing spec (use --force) and
20
- * never touches an existing playwright.config.ts. Exit 0 = done (or nothing to
21
- * do), 2 = usage error.
21
+ * never touches an existing app playwright.config.ts. Exit 0 = done (or nothing
22
+ * to do), 2 = usage error.
22
23
  */
23
24
  import fs from 'node:fs';
24
25
  import path from 'node:path';
@@ -31,7 +32,7 @@ usage: styleproof-init [options]
31
32
 
32
33
  options:
33
34
  --dir <path> spec output path (default: e2e/styleproof.spec.ts)
34
- --base-url <url> baseURL for a generated playwright.config.ts
35
+ --base-url <url> baseURL for a generated playwright.styleproof.config.ts
35
36
  (default: http://localhost:3000)
36
37
  --force overwrite the spec if it already exists
37
38
  -h, --help show this help
@@ -41,7 +42,7 @@ What it writes:
41
42
  In a Next.js app it discovers your routes at run time and wires both the
42
43
  surfaces and the \`expected\` coverage guard to them, so a new page can't ship
43
44
  uncaptured. Otherwise it writes one sample surface + a commented guard block.
44
- - playwright.config.ts, only if absent (an existing one is left untouched)
45
+ - playwright.styleproof.config.ts, a dedicated production-build Playwright config
45
46
  - .github/workflows/styleproof.yml, a cache-first PR report workflow
46
47
 
47
48
  After running, build and upload this commit's map outside CI when possible:
@@ -237,14 +238,15 @@ const PACKAGE_MANAGERS = {
237
238
  },
238
239
  pnpm: {
239
240
  label: 'pnpm',
240
- run: (script) => `npx -y pnpm ${script}`,
241
- exec: (command) => `npx -y pnpm exec ${command}`,
242
- install: 'npx -y pnpm install --frozen-lockfile',
241
+ run: (script) => `pnpm run ${script}`,
242
+ exec: (command) => `pnpm exec ${command}`,
243
+ install: 'pnpm install --frozen-lockfile',
243
244
  setup: ` - uses: actions/setup-node@v4
244
245
  with:
245
246
  node-version: '20'
246
247
  cache: pnpm
247
- cache-dependency-path: pnpm-lock.yaml`,
248
+ cache-dependency-path: pnpm-lock.yaml
249
+ - run: corepack enable`,
248
250
  },
249
251
  bun: {
250
252
  label: 'Bun',
@@ -269,6 +271,57 @@ function detectPackageManager(root) {
269
271
 
270
272
  const PM = detectPackageManager(process.cwd());
271
273
 
274
+ function readPackageJson(root) {
275
+ try {
276
+ return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
277
+ } catch {
278
+ return {};
279
+ }
280
+ }
281
+
282
+ function hasDep(pkg, name) {
283
+ return Boolean(pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]);
284
+ }
285
+
286
+ function scriptIncludes(pkg, script, text) {
287
+ return typeof pkg.scripts?.[script] === 'string' && pkg.scripts[script].includes(text);
288
+ }
289
+
290
+ function portFromBaseUrl(url) {
291
+ try {
292
+ const parsed = new URL(url);
293
+ if (parsed.port) return parsed.port;
294
+ return parsed.protocol === 'https:' ? '443' : '80';
295
+ } catch {
296
+ return '3000';
297
+ }
298
+ }
299
+
300
+ function productionServerCommand(root, base) {
301
+ const pkg = readPackageJson(root);
302
+ const port = portFromBaseUrl(base);
303
+ const build = pkg.scripts?.build ? `${PM.run('build')} && ` : '';
304
+ const looksLikeVite =
305
+ hasDep(pkg, 'vite') || scriptIncludes(pkg, 'dev', 'vite') || scriptIncludes(pkg, 'build', 'vite');
306
+ const looksLikeNext =
307
+ hasDep(pkg, 'next') || scriptIncludes(pkg, 'dev', 'next') || scriptIncludes(pkg, 'build', 'next');
308
+
309
+ if (looksLikeVite) return `${build}${PM.exec(`vite preview --host 127.0.0.1 --port ${port}`)}`;
310
+ if (looksLikeNext) {
311
+ const start = pkg.scripts?.start ? PM.run('start') : PM.exec(`next start -p ${port}`);
312
+ return `${build}${start}`;
313
+ }
314
+ if (pkg.scripts?.start) return `${build}${PM.run('start')}`;
315
+ if (pkg.scripts?.preview) return `${build}${PM.run('preview')}`;
316
+ return `${PM.run('build')} && ${PM.run('start')}`;
317
+ }
318
+
319
+ function configTestDir(spec) {
320
+ const dir = path.dirname(path.resolve(process.cwd(), spec));
321
+ const rel = path.relative(process.cwd(), dir).replace(/\\/g, '/');
322
+ return rel ? `./${rel}` : '.';
323
+ }
324
+
272
325
  const CONFIG = `import { defineConfig, devices } from '@playwright/test';
273
326
 
274
327
  // Generated by styleproof-init.
@@ -280,6 +333,8 @@ const CONFIG = `import { defineConfig, devices } from '@playwright/test';
280
333
  // app serves precompiled routes at consistent timing. (StyleProof's settle waits for
281
334
  // in-flight data either way, but a production build removes the variance at the source.)
282
335
  export default defineConfig({
336
+ testDir: ${JSON.stringify(configTestDir(specPath))},
337
+ testMatch: ${JSON.stringify(path.basename(specPath))},
283
338
  timeout: 120_000,
284
339
  // Capture surfaces in PARALLEL. StyleProof generates one test per surface × width,
285
340
  // each an isolated page writing a uniquely-keyed file (\`<key>@<width>.json.gz\`), with
@@ -293,11 +348,13 @@ export default defineConfig({
293
348
  baseURL: process.env.BASE_URL || '${baseUrl}',
294
349
  },
295
350
  // Build once, then serve THAT production build for the captures — so you can't
296
- // accidentally capture a dev server. Adjust the command to your framework (e.g.
297
- // \`vite build && vite preview\`); a server already running locally is reused.
351
+ // accidentally capture a dev server. styleproof-init detected the production
352
+ // serve command from your package scripts/dependencies; tune it here if your
353
+ // framework needs a custom preview command.
298
354
  webServer: {
299
- command: '${PM.run('build')} && ${PM.run('start')}',
355
+ command: '${productionServerCommand(process.cwd(), baseUrl)}',
300
356
  url: process.env.BASE_URL || '${baseUrl}',
357
+ env: { PORT: '${portFromBaseUrl(baseUrl)}' },
301
358
  reuseExistingServer: !process.env.CI,
302
359
  timeout: 600_000, // a cold production build can take a few minutes
303
360
  },
@@ -422,13 +479,18 @@ if (spec.wrote) {
422
479
  console.log(`${specPath} already exists — left untouched (use --force to overwrite)`);
423
480
  }
424
481
 
425
- const configPath = 'playwright.config.ts';
426
- if (fs.existsSync(configPath) || fs.existsSync('playwright.config.js')) {
427
- console.log('playwright.config exists — left untouched; ensure its baseURL points at a production build');
428
- } else {
429
- fs.writeFileSync(configPath, CONFIG);
430
- console.log(`created ${configPath} (baseURL ${baseUrl})`);
482
+ const configPath = 'playwright.styleproof.config.ts';
483
+ const config = writeFileSafe(configPath, CONFIG, { force });
484
+ if (config.wrote) {
485
+ console.log(`${config.exists ? 'overwrote' : 'created'} ${configPath} (dedicated StyleProof capture config)`);
431
486
  wroteSomething = true;
487
+ } else {
488
+ console.log(`${configPath} already exists — left untouched (use --force to overwrite)`);
489
+ }
490
+ if (fs.existsSync('playwright.config.ts') || fs.existsSync('playwright.config.js')) {
491
+ console.log(
492
+ 'app playwright.config exists — left untouched; styleproof-map uses playwright.styleproof.config.ts by default',
493
+ );
432
494
  }
433
495
 
434
496
  const ignored = ['.styleproof/', 'test-results/', 'playwright-report/'].filter((line) => ensureGitignoreLine(line));
@@ -13,6 +13,7 @@
13
13
  import fs from 'node:fs';
14
14
  import { spawnSync } from 'node:child_process';
15
15
  import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
16
17
  import {
17
18
  isHelpArg,
18
19
  missingSpecMessage,
@@ -34,6 +35,8 @@ import {
34
35
  writeMapManifest,
35
36
  } from '../dist/map-store.js';
36
37
 
38
+ const STYLEPROOF_PLAYWRIGHT_CONFIG = 'playwright.styleproof.config.ts';
39
+ const STYLEPROOF_VARIANTS_SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'styleproof-variants.mjs');
37
40
  const HELP = `styleproof-map — capture this branch's computed-style map
38
41
 
39
42
  usage: styleproof-map [options] [-- <playwright args>]
@@ -49,12 +52,28 @@ options:
49
52
  --upload require upload to the map store branch after capture
50
53
  --no-upload capture locally only (default in CI)
51
54
  --restore restore a map from the map store instead of capturing
55
+ --crawl-base-url <url>
56
+ run styleproof-variants before capture against this app URL
57
+ --crawl-route <r> route path or key=path for the pre-map variant crawl; repeatable
58
+ --crawl-out <file> variant crawl manifest (default: styleproof.variants.generated.json)
59
+ --crawl-max-actions <n>
60
+ max attempted variant actions per route (default: 40)
61
+ --crawl-width <px> pre-map crawl viewport width (default: 1280)
62
+ --crawl-height <px> pre-map crawl viewport height (default: 800)
63
+ --crawl-strict fail if live-state fixtures or skipped candidates remain
52
64
  --cache-branch <b> map store branch (default: ${DEFAULT_MAP_STORE_BRANCH})
53
65
  --remote <name> git remote for the map store (default: ${DEFAULT_REMOTE})
54
66
  -h, --help show this help
55
67
 
68
+ If playwright.styleproof.config.ts exists, styleproof-map passes it to Playwright
69
+ by default. Override with: styleproof-map -- --config playwright.config.ts
70
+
71
+ Set STYLEPROOF_CRAWL_BASE_URL and STYLEPROOF_CRAWL_ROUTES (comma-separated) to
72
+ run the same pre-map crawl from automation.
73
+
56
74
  Examples:
57
75
  styleproof-map
76
+ styleproof-map --crawl-base-url http://localhost:3000 --crawl-route / --crawl-route settings=/settings
58
77
  styleproof-map --upload
59
78
  styleproof-map --restore --sha 0123abcd --dir head --base-dir __stylemaps__
60
79
  styleproof-map --spec e2e/styleproof.spec.ts
@@ -73,6 +92,16 @@ let cacheBranch = process.env.STYLEPROOF_CACHE_BRANCH ?? DEFAULT_MAP_STORE_BRANC
73
92
  let remote = process.env.STYLEPROOF_REMOTE ?? DEFAULT_REMOTE;
74
93
  let uploadMode =
75
94
  process.env.STYLEPROOF_UPLOAD === '1' ? 'required' : process.env.STYLEPROOF_UPLOAD === '0' ? 'off' : 'auto';
95
+ let crawlBaseUrl = process.env.STYLEPROOF_CRAWL_BASE_URL ?? '';
96
+ const crawlRoutes = (process.env.STYLEPROOF_CRAWL_ROUTES ?? '')
97
+ .split(',')
98
+ .map((route) => route.trim())
99
+ .filter(Boolean);
100
+ let crawlOut = process.env.STYLEPROOF_CRAWL_OUT ?? 'styleproof.variants.generated.json';
101
+ let crawlMaxActions = process.env.STYLEPROOF_CRAWL_MAX_ACTIONS ?? '';
102
+ let crawlWidth = process.env.STYLEPROOF_CRAWL_WIDTH ?? '';
103
+ let crawlHeight = process.env.STYLEPROOF_CRAWL_HEIGHT ?? '';
104
+ let crawlStrict = process.env.STYLEPROOF_CRAWL_STRICT === '1';
76
105
  const playwrightArgs = [];
77
106
 
78
107
  for (let i = 0; i < argv.length; i++) {
@@ -95,9 +124,24 @@ for (let i = 0; i < argv.length; i++) {
95
124
  else if (a === '--upload') uploadMode = 'required';
96
125
  else if (a === '--no-upload') uploadMode = 'off';
97
126
  else if (a === '--restore') restore = true;
98
- else if (a === '--cache-branch') cacheBranch = argv[++i];
99
- else if (a.startsWith('--cache-branch=')) cacheBranch = a.slice(15);
100
- else if (a === '--remote') remote = argv[++i];
127
+ else if (a === '--crawl-base-url') crawlBaseUrl = argv[++i];
128
+ else if (a.startsWith('--crawl-base-url=')) crawlBaseUrl = a.slice(17);
129
+ else if (a === '--crawl-route') crawlRoutes.push(argv[++i]);
130
+ else if (a.startsWith('--crawl-route=')) crawlRoutes.push(a.slice(14));
131
+ else if (a === '--crawl-out') crawlOut = argv[++i];
132
+ else if (a.startsWith('--crawl-out=')) crawlOut = a.slice(12);
133
+ else if (a === '--crawl-max-actions') crawlMaxActions = argv[++i];
134
+ else if (a.startsWith('--crawl-max-actions=')) crawlMaxActions = a.slice(20);
135
+ else if (a === '--crawl-width') crawlWidth = argv[++i];
136
+ else if (a.startsWith('--crawl-width=')) crawlWidth = a.slice(14);
137
+ else if (a === '--crawl-height') crawlHeight = argv[++i];
138
+ else if (a.startsWith('--crawl-height=')) crawlHeight = a.slice(15);
139
+ else if (a === '--crawl-strict') crawlStrict = true;
140
+ else if (a === '--cache-branch' || a === '--remote') {
141
+ const value = argv[++i];
142
+ if (a === '--cache-branch') cacheBranch = value;
143
+ else remote = value;
144
+ } else if (a.startsWith('--cache-branch=')) cacheBranch = a.slice(15);
101
145
  else if (a.startsWith('--remote=')) remote = a.slice(9);
102
146
  else if (a.startsWith('--')) {
103
147
  console.error(unknownFlagMessage('styleproof-map', a));
@@ -123,6 +167,15 @@ if (!fs.existsSync(spec)) {
123
167
  console.error(missingSpecMessage(spec));
124
168
  process.exit(2);
125
169
  }
170
+ const crawlEnabled = Boolean(crawlBaseUrl || crawlRoutes.length);
171
+ if (crawlEnabled && !crawlBaseUrl) {
172
+ console.error('styleproof-map: --crawl-base-url is required when --crawl-route is set');
173
+ process.exit(2);
174
+ }
175
+ if (crawlEnabled && !crawlRoutes.length) {
176
+ console.error('styleproof-map: at least one --crawl-route is required when --crawl-base-url is set');
177
+ process.exit(2);
178
+ }
126
179
  if (restore && !sha) {
127
180
  try {
128
181
  sha = currentGitSha(process.cwd());
@@ -165,6 +218,39 @@ function upload(dirPath) {
165
218
  }
166
219
  }
167
220
 
221
+ function hasPlaywrightConfigArg(args) {
222
+ return args.some((arg) => arg === '--config' || arg === '-c' || arg.startsWith('--config='));
223
+ }
224
+
225
+ function variantCrawlArgs() {
226
+ const args = ['--base-url', crawlBaseUrl, '--out', crawlOut];
227
+ for (const route of crawlRoutes) args.push('--route', route);
228
+ if (crawlMaxActions) args.push('--max-actions', crawlMaxActions);
229
+ if (crawlWidth) args.push('--width', crawlWidth);
230
+ if (crawlHeight) args.push('--height', crawlHeight);
231
+ if (crawlStrict) args.push('--strict');
232
+ return args;
233
+ }
234
+
235
+ function runVariantCrawl(env) {
236
+ if (!crawlEnabled) return;
237
+ console.error('styleproof-map: crawling UI variants before capture');
238
+ const command = process.platform === 'win32' ? 'styleproof-variants.cmd' : 'styleproof-variants';
239
+ let result = spawnSync(command, variantCrawlArgs(), { stdio: 'inherit', env });
240
+ if (result.error?.code === 'ENOENT') {
241
+ result = spawnSync(process.execPath, [STYLEPROOF_VARIANTS_SCRIPT, ...variantCrawlArgs()], {
242
+ stdio: 'inherit',
243
+ env,
244
+ });
245
+ }
246
+ if (result.error) {
247
+ console.error(`styleproof-map: could not run styleproof-variants\n${result.error.message}`);
248
+ process.exit(2);
249
+ }
250
+ const status = result.status ?? 1;
251
+ if (status !== 0) process.exit(status);
252
+ }
253
+
168
254
  const targetDir = path.join(baseDir, dir);
169
255
  let dirtyBeforeCapture;
170
256
  try {
@@ -199,13 +285,18 @@ if (restore) {
199
285
  }
200
286
 
201
287
  const command = process.platform === 'win32' ? 'playwright.cmd' : 'playwright';
288
+ const configArgs =
289
+ fs.existsSync(STYLEPROOF_PLAYWRIGHT_CONFIG) && !hasPlaywrightConfigArg(playwrightArgs)
290
+ ? ['--config', STYLEPROOF_PLAYWRIGHT_CONFIG]
291
+ : [];
202
292
  const env = {
203
293
  ...process.env,
204
294
  STYLEMAP_DIR: dir,
205
295
  STYLEPROOF_BASEDIR: baseDir,
206
296
  STYLEPROOF_SCREENSHOTS: screenshots,
207
297
  };
208
- const result = spawnSync(command, ['test', '--grep', 'styleproof capture', ...playwrightArgs], {
298
+ runVariantCrawl(env);
299
+ const result = spawnSync(command, ['test', '--grep', 'styleproof capture', ...configArgs, ...playwrightArgs], {
209
300
  stdio: 'inherit',
210
301
  env,
211
302
  });
package/dist/map-store.js CHANGED
@@ -10,6 +10,7 @@ export const DEFAULT_MAP_LABEL = 'current';
10
10
  export const DEFAULT_MAP_STORE_BRANCH = 'styleproof-maps';
11
11
  export const DEFAULT_REMOTE = 'origin';
12
12
  export const MAP_MANIFEST = 'styleproof-manifest.json';
13
+ const GENERATED_DIRTY_ALLOWLIST = new Set(['next-env.d.ts']);
13
14
  export class MapStoreError extends Error {
14
15
  }
15
16
  function runGit(cwd, args, maxBuffer = 1 << 28) {
@@ -112,7 +113,14 @@ export function refSha(ref, cwd = process.cwd()) {
112
113
  return sha;
113
114
  }
114
115
  export function workingTreeDirty(cwd = process.cwd()) {
115
- return gitOutput(cwd, ['status', '--porcelain']) !== '';
116
+ const r = runGit(cwd, ['status', '--porcelain']);
117
+ const status = r.status === 0 ? r.stdout.trimEnd() : '';
118
+ if (!status)
119
+ return false;
120
+ return status.split(/\r?\n/).some((line) => {
121
+ const file = line.slice(3).trim();
122
+ return file && !GENERATED_DIRTY_ALLOWLIST.has(file);
123
+ });
116
124
  }
117
125
  export function remoteExists(remote = DEFAULT_REMOTE, cwd = process.cwd()) {
118
126
  return runGit(cwd, ['remote', 'get-url', remote], 1 << 20).status === 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.1.5",
3
+ "version": "3.2.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",