styleproof 3.1.4 → 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.
@@ -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:
@@ -176,9 +177,27 @@ const SURFACES: Surface[] = [
176
177
  // No widths → StyleProof detects your @media breakpoints from the loaded CSS and
177
178
  // sweeps one viewport per band. Pass an explicit array (e.g. 1280, 768, 390) to pin them (or to
178
179
  // cover a JS-only matchMedia breakpoint that has no CSS @media rule).
180
+ // Non-live UI states belong here as variants, so the base branch's
181
+ // dialog-open state compares to the head branch's dialog-open state.
182
+ // variants: [
183
+ // {
184
+ // key: 'dialog-open',
185
+ // go: async (page) => {
186
+ // await page.getByRole('button', { name: /open settings/i }).click();
187
+ // await page.getByRole('dialog').waitFor();
188
+ // },
189
+ // },
190
+ // {
191
+ // key: 'popover-open',
192
+ // go: async (page) => {
193
+ // await page.getByRole('button', { name: /more/i }).click();
194
+ // await page.locator('[popover], [role="menu"]').first().waitFor();
195
+ // },
196
+ // },
197
+ // ],
179
198
  },
180
- // Add one surface per distinct page state: open menus, dialogs, selected
181
- // tabs, form errors anything whose styling you want certified.
199
+ // Add more surfaces for distinct routes/views; add menus, dialogs, popovers,
200
+ // selected tabs, and form errors as variants of the route/view that owns them.
182
201
  ];
183
202
 
184
203
  defineStyleMapCapture({
@@ -219,14 +238,15 @@ const PACKAGE_MANAGERS = {
219
238
  },
220
239
  pnpm: {
221
240
  label: 'pnpm',
222
- run: (script) => `npx -y pnpm ${script}`,
223
- exec: (command) => `npx -y pnpm exec ${command}`,
224
- 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',
225
244
  setup: ` - uses: actions/setup-node@v4
226
245
  with:
227
246
  node-version: '20'
228
247
  cache: pnpm
229
- cache-dependency-path: pnpm-lock.yaml`,
248
+ cache-dependency-path: pnpm-lock.yaml
249
+ - run: corepack enable`,
230
250
  },
231
251
  bun: {
232
252
  label: 'Bun',
@@ -251,6 +271,57 @@ function detectPackageManager(root) {
251
271
 
252
272
  const PM = detectPackageManager(process.cwd());
253
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
+
254
325
  const CONFIG = `import { defineConfig, devices } from '@playwright/test';
255
326
 
256
327
  // Generated by styleproof-init.
@@ -262,6 +333,8 @@ const CONFIG = `import { defineConfig, devices } from '@playwright/test';
262
333
  // app serves precompiled routes at consistent timing. (StyleProof's settle waits for
263
334
  // in-flight data either way, but a production build removes the variance at the source.)
264
335
  export default defineConfig({
336
+ testDir: ${JSON.stringify(configTestDir(specPath))},
337
+ testMatch: ${JSON.stringify(path.basename(specPath))},
265
338
  timeout: 120_000,
266
339
  // Capture surfaces in PARALLEL. StyleProof generates one test per surface × width,
267
340
  // each an isolated page writing a uniquely-keyed file (\`<key>@<width>.json.gz\`), with
@@ -275,11 +348,13 @@ export default defineConfig({
275
348
  baseURL: process.env.BASE_URL || '${baseUrl}',
276
349
  },
277
350
  // Build once, then serve THAT production build for the captures — so you can't
278
- // accidentally capture a dev server. Adjust the command to your framework (e.g.
279
- // \`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.
280
354
  webServer: {
281
- command: '${PM.run('build')} && ${PM.run('start')}',
355
+ command: '${productionServerCommand(process.cwd(), baseUrl)}',
282
356
  url: process.env.BASE_URL || '${baseUrl}',
357
+ env: { PORT: '${portFromBaseUrl(baseUrl)}' },
283
358
  reuseExistingServer: !process.env.CI,
284
359
  timeout: 600_000, // a cold production build can take a few minutes
285
360
  },
@@ -404,13 +479,18 @@ if (spec.wrote) {
404
479
  console.log(`${specPath} already exists — left untouched (use --force to overwrite)`);
405
480
  }
406
481
 
407
- const configPath = 'playwright.config.ts';
408
- if (fs.existsSync(configPath) || fs.existsSync('playwright.config.js')) {
409
- console.log('playwright.config exists — left untouched; ensure its baseURL points at a production build');
410
- } else {
411
- fs.writeFileSync(configPath, CONFIG);
412
- 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)`);
413
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
+ );
414
494
  }
415
495
 
416
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
  });
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Harvest one-step UI state variants from a running app.
4
+ *
5
+ * styleproof-variants --base-url http://localhost:3000 --route / --route /settings
6
+ */
7
+ import fs from 'node:fs';
8
+ import { chromium } from '@playwright/test';
9
+ import { harvestStyleVariants } from '../dist/variant-crawler.js';
10
+ import { defaultLinkKey } from '../dist/crawl.js';
11
+ import { isHelpArg, showHelpAndExit, unknownFlagMessage } from '../dist/cli-errors.js';
12
+
13
+ const HELP = `styleproof-variants — discover one-step UI state variants
14
+
15
+ usage: styleproof-variants --base-url <url> --route <path-or-key=path> [options]
16
+
17
+ options:
18
+ --base-url <url> running app origin, e.g. http://localhost:3000
19
+ --route <route> route path, absolute URL, or key=path. Repeatable.
20
+ --out <file> manifest output (default: styleproof.variants.generated.json)
21
+ --max-actions <n> max attempted actions per route (default: 40)
22
+ --width <px> viewport width (default: 1280)
23
+ --height <px> viewport height (default: 800)
24
+ --strict exit 1 if live-state fixtures or skipped candidates remain
25
+ -h, --help show this help
26
+ `;
27
+
28
+ const argv = process.argv.slice(2);
29
+ let baseUrl = '';
30
+ let out = 'styleproof.variants.generated.json';
31
+ let maxActions = 40;
32
+ let width = 1280;
33
+ let height = 800;
34
+ let strict = false;
35
+ const routeArgs = [];
36
+
37
+ for (let i = 0; i < argv.length; i++) {
38
+ const a = argv[i];
39
+ if (isHelpArg(a)) showHelpAndExit(HELP);
40
+ else if (a === '--base-url') baseUrl = argv[++i];
41
+ else if (a.startsWith('--base-url=')) baseUrl = a.slice(11);
42
+ else if (a === '--route') routeArgs.push(argv[++i]);
43
+ else if (a.startsWith('--route=')) routeArgs.push(a.slice(8));
44
+ else if (a === '--out') out = argv[++i];
45
+ else if (a.startsWith('--out=')) out = a.slice(6);
46
+ else if (a === '--max-actions') maxActions = Number(argv[++i]);
47
+ else if (a.startsWith('--max-actions=')) maxActions = Number(a.slice(14));
48
+ else if (a === '--width') width = Number(argv[++i]);
49
+ else if (a.startsWith('--width=')) width = Number(a.slice(8));
50
+ else if (a === '--height') height = Number(argv[++i]);
51
+ else if (a.startsWith('--height=')) height = Number(a.slice(9));
52
+ else if (a === '--strict') strict = true;
53
+ else if (a.startsWith('--')) {
54
+ console.error(unknownFlagMessage('styleproof-variants', a));
55
+ process.exit(2);
56
+ } else {
57
+ routeArgs.push(a);
58
+ }
59
+ }
60
+
61
+ if (!baseUrl) {
62
+ console.error('styleproof-variants: --base-url is required');
63
+ process.exit(2);
64
+ }
65
+ if (!routeArgs.length) {
66
+ console.error('styleproof-variants: at least one --route is required');
67
+ process.exit(2);
68
+ }
69
+ if (![maxActions, width, height].every(Number.isFinite)) {
70
+ console.error('styleproof-variants: --max-actions, --width, and --height must be numbers');
71
+ process.exit(2);
72
+ }
73
+
74
+ function parseRoute(input) {
75
+ const eq = input.indexOf('=');
76
+ if (eq > 0) return { key: input.slice(0, eq), url: input.slice(eq + 1) };
77
+ return { key: defaultLinkKey(new URL(input, baseUrl)), url: input };
78
+ }
79
+
80
+ const browser = await chromium.launch();
81
+ try {
82
+ const page = await browser.newPage({ viewport: { width, height } });
83
+ const harvest = await harvestStyleVariants(page, {
84
+ baseUrl,
85
+ routes: routeArgs.map(parseRoute),
86
+ maxActionsPerRoute: maxActions,
87
+ });
88
+ fs.writeFileSync(out, JSON.stringify(harvest, null, 2) + '\n');
89
+ const variants = harvest.routes.reduce((sum, route) => sum + route.variants.length, 0);
90
+ const liveStates = harvest.routes.reduce((sum, route) => sum + route.liveStates.length, 0);
91
+ const skipped = harvest.routes.reduce((sum, route) => sum + route.skipped.length, 0);
92
+ console.log(`styleproof-variants: wrote ${out}`);
93
+ console.log(`${variants} variant(s), ${liveStates} live-state candidate(s), ${skipped} skipped candidate(s)`);
94
+ if (strict && (liveStates || skipped)) process.exit(1);
95
+ } finally {
96
+ await browser.close();
97
+ }
@@ -0,0 +1,38 @@
1
+ export type ActionContextInput = {
2
+ eventName: string;
3
+ payload: {
4
+ pull_request?: {
5
+ number?: number;
6
+ head?: {
7
+ sha?: string;
8
+ };
9
+ };
10
+ workflow_run?: {
11
+ head_sha?: string;
12
+ pull_requests?: {
13
+ number?: number;
14
+ }[];
15
+ };
16
+ };
17
+ repo: Record<string, string>;
18
+ github: {
19
+ rest: {
20
+ repos: {
21
+ listPullRequestsAssociatedWithCommit: (args: Record<string, string>) => Promise<{
22
+ data: {
23
+ state?: string;
24
+ number?: number;
25
+ head?: {
26
+ sha?: string;
27
+ };
28
+ }[];
29
+ }>;
30
+ };
31
+ };
32
+ };
33
+ };
34
+ export type ActionContextResult = {
35
+ prNumber: string;
36
+ headSha: string;
37
+ };
38
+ export declare function resolveActionContext({ eventName, payload, repo, github, }: ActionContextInput): Promise<ActionContextResult>;
@@ -0,0 +1,17 @@
1
+ export async function resolveActionContext({ eventName, payload, repo, github, }) {
2
+ let prNumber = payload.pull_request?.number;
3
+ let headSha = payload.pull_request?.head?.sha;
4
+ if (eventName === 'workflow_run') {
5
+ const run = payload.workflow_run;
6
+ headSha = run?.head_sha;
7
+ prNumber = run?.pull_requests?.[0]?.number;
8
+ if (headSha && !prNumber) {
9
+ const { data } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
10
+ ...repo,
11
+ commit_sha: headSha,
12
+ });
13
+ prNumber = data.find((pr) => pr.state === 'open' && pr.head?.sha === headSha)?.number;
14
+ }
15
+ }
16
+ return prNumber && headSha ? { prNumber: String(prNumber), headSha } : { prNumber: '', headSha: '' };
17
+ }
package/dist/capture.d.ts CHANGED
@@ -69,6 +69,16 @@ export type LiveRegionCandidate = {
69
69
  ariaLive?: string;
70
70
  ariaBusy?: string;
71
71
  };
72
+ export type CapturedOverlay = {
73
+ path: string;
74
+ tag: string;
75
+ cls: string;
76
+ reason: string;
77
+ role?: string;
78
+ ariaModal?: string;
79
+ ariaLive?: string;
80
+ text?: string;
81
+ };
72
82
  export type StyleMap = {
73
83
  /** Optional runner-supplied context; ignored by the certification diff. */
74
84
  metadata?: CaptureMetadata;
@@ -98,6 +108,14 @@ export type StyleMap = {
98
108
  * regions that actually keep changing are auto-excluded via `volatile`.
99
109
  */
100
110
  liveCandidates?: LiveRegionCandidate[];
111
+ /**
112
+ * Visible semantic overlay roots that were present in the captured DOM and
113
+ * whose paths are present in `elements`. This is diagnostic/proof metadata:
114
+ * dialogs, menus, listboxes, modal roots and toast roots are still certified by
115
+ * the normal computed-style map, while this list lets tests prove those states
116
+ * were actually reached and captured.
117
+ */
118
+ overlays?: CapturedOverlay[];
101
119
  /**
102
120
  * Colour-valued `:root` custom properties (design/theme tokens), normalised to
103
121
  * the same `rgb(...)` form the longhands resolve to — `{ "--red-200": "rgb(254,
package/dist/capture.js CHANGED
@@ -283,6 +283,75 @@ function detectLiveCandidates({ ignore }) {
283
283
  : [];
284
284
  });
285
285
  }
286
+ function detectOverlayCandidates({ ignore }) {
287
+ const pathOf = window.__spPathOf;
288
+ const skipSel = ignore.length ? ignore.map((s) => `${s}, ${s} *`).join(', ') : '';
289
+ const visible = (el) => {
290
+ if (el.hidden || el.getAttribute('aria-hidden') === 'true')
291
+ return false;
292
+ const rect = el.getBoundingClientRect();
293
+ const style = getComputedStyle(el);
294
+ return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
295
+ };
296
+ const textOf = (el) => (el.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 120);
297
+ const toastish = (el) => {
298
+ const haystack = [
299
+ el.id,
300
+ el.getAttribute('class'),
301
+ el.getAttribute('data-testid'),
302
+ el.getAttribute('data-hot-toast'),
303
+ el.getAttribute('data-sonner-toast'),
304
+ el.getAttribute('data-toast'),
305
+ ]
306
+ .filter(Boolean)
307
+ .join(' ')
308
+ .toLowerCase();
309
+ return /\b(toast|hot-toast|sonner)\b/.test(haystack);
310
+ };
311
+ const reasonsFor = (el, role, ariaModal) => {
312
+ const reasons = [];
313
+ if (el instanceof HTMLDialogElement && el.open)
314
+ reasons.push('dialog[open]');
315
+ if (el.hasAttribute('popover'))
316
+ reasons.push('popover');
317
+ if (['dialog', 'alertdialog', 'menu', 'listbox', 'tooltip'].includes(role))
318
+ reasons.push(`role=${role}`);
319
+ if (ariaModal === 'true')
320
+ reasons.push('aria-modal=true');
321
+ if (toastish(el))
322
+ reasons.push('toast');
323
+ if (el.hasAttribute('data-hot-toast'))
324
+ reasons.push('data-hot-toast');
325
+ if (el.hasAttribute('data-sonner-toast'))
326
+ reasons.push('data-sonner-toast');
327
+ if ((role === 'status' || role === 'alert') && toastish(el))
328
+ reasons.push(`role=${role}`);
329
+ return [...new Set(reasons)];
330
+ };
331
+ return [document.documentElement, document.body, ...document.querySelectorAll('body *')]
332
+ .filter((el) => (!skipSel || !el.matches(skipSel)) && visible(el))
333
+ .flatMap((el) => {
334
+ const role = (el.getAttribute('role') ?? '').trim().toLowerCase();
335
+ const ariaModal = (el.getAttribute('aria-modal') ?? '').trim().toLowerCase();
336
+ const ariaLive = (el.getAttribute('aria-live') ?? '').trim().toLowerCase();
337
+ const reasons = reasonsFor(el, role, ariaModal);
338
+ if (!reasons.length)
339
+ return [];
340
+ const text = textOf(el);
341
+ return [
342
+ {
343
+ path: pathOf(el),
344
+ tag: el.tagName.toLowerCase(),
345
+ cls: el.getAttribute('class') || '',
346
+ reason: reasons.join(', '),
347
+ ...(role ? { role } : {}),
348
+ ...(ariaModal ? { ariaModal } : {}),
349
+ ...(ariaLive ? { ariaLive } : {}),
350
+ ...(text ? { text } : {}),
351
+ },
352
+ ];
353
+ });
354
+ }
286
355
  /** Full (unpruned) computed styles for an element and its descendants, pseudo-elements included. */
287
356
  // Serialized into the browser by page.evaluate; cannot call module helpers.
288
357
  function snapSubtree({ selector, index }) {
@@ -650,6 +719,7 @@ export async function captureStyleMap(page, options = {}) {
650
719
  await page.addStyleTag({ content: FREEZE_CSS });
651
720
  const base = await page.evaluate(capturePage, { ignore, motionOnly: false, captureText, captureComponent });
652
721
  dropVolatile(base.elements, volatile);
722
+ const overlays = (await page.evaluate(detectOverlayCandidates, { ignore })).filter((overlay) => base.elements[overlay.path]);
653
723
  warnUntraversed(base.shadowHosts, base.sameOriginFrames);
654
724
  mergeMotion(base.elements, motion.elements);
655
725
  let states = {};
@@ -668,6 +738,7 @@ export async function captureStyleMap(page, options = {}) {
668
738
  ...(statesSkipped ? { statesSkipped: true } : {}),
669
739
  ...(volatile.length ? { volatile } : {}),
670
740
  ...(liveCandidates.length ? { liveCandidates } : {}),
741
+ ...(overlays.length ? { overlays } : {}),
671
742
  ...(Object.keys(tokens).length ? { tokens } : {}),
672
743
  };
673
744
  }