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.
- package/CHANGELOG.md +69 -1
- package/README.md +307 -55
- package/bin/styleproof-init.mjs +101 -21
- package/bin/styleproof-map.mjs +95 -4
- package/bin/styleproof-variants.mjs +97 -0
- package/dist/action-context.d.ts +38 -0
- package/dist/action-context.js +17 -0
- package/dist/capture.d.ts +18 -0
- package/dist/capture.js +71 -0
- package/dist/components.d.ts +41 -0
- package/dist/components.js +101 -0
- package/dist/coverage.d.ts +7 -6
- package/dist/coverage.js +7 -6
- package/dist/diff.js +22 -2
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -0
- package/dist/map-store.js +9 -1
- package/dist/runner.d.ts +4 -2
- package/dist/runner.js +33 -10
- package/dist/variant-crawler.d.ts +60 -0
- package/dist/variant-crawler.js +275 -0
- package/package.json +5 -3
package/bin/styleproof-init.mjs
CHANGED
|
@@ -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:
|
|
15
|
-
* Playwright
|
|
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
|
|
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,
|
|
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
|
|
181
|
-
// tabs, form errors
|
|
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) => `
|
|
223
|
-
exec: (command) => `
|
|
224
|
-
install: '
|
|
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.
|
|
279
|
-
//
|
|
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: '${
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
}
|
|
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));
|
package/bin/styleproof-map.mjs
CHANGED
|
@@ -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 === '--
|
|
99
|
-
else if (a.startsWith('--
|
|
100
|
-
else if (a === '--
|
|
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
|
-
|
|
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
|
}
|