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.
@@ -0,0 +1,275 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { captureStyleMap } from './capture.js';
3
+ import { diffStyleMaps } from './diff.js';
4
+ function slug(value) {
5
+ return (value
6
+ .toLowerCase()
7
+ .replace(/[^a-z0-9]+/g, '-')
8
+ .replace(/^-+|-+$/g, '')
9
+ .slice(0, 48) || 'state');
10
+ }
11
+ function routeUrl(route, baseUrl) {
12
+ if (!baseUrl)
13
+ return route.url;
14
+ return new URL(route.url, baseUrl).href;
15
+ }
16
+ function pathAndSearch(url) {
17
+ const parsed = new URL(url);
18
+ return parsed.pathname + parsed.search;
19
+ }
20
+ function diffHash(findings) {
21
+ return createHash('sha256').update(JSON.stringify(findings)).digest('hex').slice(0, 16);
22
+ }
23
+ function variantKey(candidate) {
24
+ if (candidate.reason === 'tab')
25
+ return `${slug(candidate.label)}-tab`;
26
+ if (candidate.reason === 'form-validation')
27
+ return `${slug(candidate.label)}-errors`;
28
+ if (candidate.reason === 'select-option')
29
+ return `${slug(candidate.label)}-selected`;
30
+ if (candidate.reason === 'aria-expanded')
31
+ return `${slug(candidate.label)}-expanded`;
32
+ if (candidate.reason === 'aria-haspopup')
33
+ return `${slug(candidate.label)}-open`;
34
+ return slug(candidate.label);
35
+ }
36
+ function liveKey(candidate) {
37
+ return slug(candidate.cls || candidate.role || candidate.ariaLive || candidate.reason || candidate.tag);
38
+ }
39
+ function liveSelector(candidate) {
40
+ return candidate.path;
41
+ }
42
+ // fallow-ignore-next-line complexity
43
+ function collectCandidates() {
44
+ const controls = [
45
+ '[aria-expanded]',
46
+ '[aria-haspopup]',
47
+ 'button',
48
+ 'summary',
49
+ '[role="button"]',
50
+ '[role="tab"]',
51
+ '[role="menuitem"]',
52
+ '[role="combobox"]',
53
+ 'select',
54
+ 'form',
55
+ ].join(',');
56
+ const dangerous = /\b(delete|remove|destroy|logout|log out|sign out|publish|deploy|pay|purchase|buy|checkout|archive|disconnect)\b/i;
57
+ const esc = (value) => CSS.escape(value);
58
+ const quote = (value) => JSON.stringify(value);
59
+ const visible = (el) => {
60
+ const box = el.getBoundingClientRect();
61
+ const cs = getComputedStyle(el);
62
+ return box.width > 0 && box.height > 0 && cs.display !== 'none' && cs.visibility !== 'hidden';
63
+ };
64
+ const unique = (selector) => document.querySelectorAll(selector).length === 1;
65
+ const pathSelector = (el) => {
66
+ const parts = [];
67
+ let cur = el;
68
+ while (cur && cur !== document.documentElement) {
69
+ const tag = cur.tagName.toLowerCase();
70
+ let index = 1;
71
+ for (let sib = cur.previousElementSibling; sib; sib = sib.previousElementSibling) {
72
+ if (sib.tagName === cur.tagName)
73
+ index++;
74
+ }
75
+ parts.unshift(`${tag}:nth-of-type(${index})`);
76
+ cur = cur.parentElement;
77
+ }
78
+ return parts.join(' > ');
79
+ };
80
+ const selectorFor = (el) => {
81
+ const attrs = ['data-testid', 'data-test', 'aria-label', 'name'];
82
+ const id = el.getAttribute('id');
83
+ if (id && unique(`#${esc(id)}`))
84
+ return `#${esc(id)}`;
85
+ for (const attr of attrs) {
86
+ const value = el.getAttribute(attr);
87
+ if (!value)
88
+ continue;
89
+ const selector = `${el.tagName.toLowerCase()}[${attr}=${quote(value)}]`;
90
+ if (unique(selector))
91
+ return selector;
92
+ }
93
+ return pathSelector(el);
94
+ };
95
+ const labelFor = (el) => {
96
+ const own = (el.getAttribute('aria-label') || el.getAttribute('name') || el.textContent || '').trim();
97
+ return own.replace(/\s+/g, ' ').slice(0, 80) || el.tagName.toLowerCase();
98
+ };
99
+ const reasonFor = (el) => {
100
+ if (el.getAttribute('role') === 'tab')
101
+ return 'tab';
102
+ if (el.tagName.toLowerCase() === 'form')
103
+ return 'form-validation';
104
+ if (el.tagName.toLowerCase() === 'select')
105
+ return 'select-option';
106
+ if (el.hasAttribute('aria-expanded'))
107
+ return 'aria-expanded';
108
+ if (el.hasAttribute('aria-haspopup'))
109
+ return 'aria-haspopup';
110
+ return 'semantic-click';
111
+ };
112
+ const seen = new Set();
113
+ const out = [];
114
+ for (const el of [...document.querySelectorAll(controls)]) {
115
+ if (el instanceof HTMLAnchorElement && el.href)
116
+ continue;
117
+ if (el.matches(':disabled,[aria-disabled="true"]'))
118
+ continue;
119
+ if (!visible(el))
120
+ continue;
121
+ const selector = selectorFor(el);
122
+ if (seen.has(selector))
123
+ continue;
124
+ seen.add(selector);
125
+ const label = labelFor(el);
126
+ if (dangerous.test(label)) {
127
+ out.push({ action: 'click', selector, reason: 'unsafe-label', label });
128
+ continue;
129
+ }
130
+ if (el instanceof HTMLFormElement) {
131
+ if (el.noValidate || !el.querySelector('input[required],textarea[required],select[required]'))
132
+ continue;
133
+ out.push({ action: 'submit-empty', selector, reason: 'form-validation', label });
134
+ }
135
+ else if (el instanceof HTMLSelectElement) {
136
+ const next = [...el.options].find((o) => !o.disabled && o.value !== el.value);
137
+ if (next)
138
+ out.push({ action: 'select-option', selector, reason: 'select-option', label, value: next.value });
139
+ }
140
+ else {
141
+ out.push({ action: 'click', selector, reason: reasonFor(el), label });
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+ async function discoverCandidates(page) {
147
+ return page.evaluate(collectCandidates);
148
+ }
149
+ async function perform(page, candidate) {
150
+ const target = page.locator(candidate.selector).first();
151
+ if (candidate.action === 'select-option') {
152
+ await target.selectOption(candidate.value ?? '');
153
+ }
154
+ else if (candidate.action === 'submit-empty') {
155
+ await target.evaluate((node) => {
156
+ const form = node;
157
+ for (const control of form.querySelectorAll('input, textarea, select')) {
158
+ if (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement)
159
+ control.value = '';
160
+ if (control instanceof HTMLSelectElement)
161
+ control.selectedIndex = -1;
162
+ }
163
+ form.requestSubmit();
164
+ });
165
+ }
166
+ else {
167
+ await target.click();
168
+ }
169
+ }
170
+ function captureOptions(options) {
171
+ return {
172
+ ignore: options.ignore,
173
+ stabilize: options.stabilize,
174
+ captureStates: false,
175
+ };
176
+ }
177
+ function liveStatesFrom(candidates = []) {
178
+ return candidates.map((candidate) => ({
179
+ key: liveKey(candidate),
180
+ selector: liveSelector(candidate),
181
+ reason: candidate.reason,
182
+ label: candidate.cls || candidate.role || candidate.tag,
183
+ fixtureRequired: true,
184
+ ...(candidate.role ? { role: candidate.role } : {}),
185
+ ...(candidate.ariaLive ? { ariaLive: candidate.ariaLive } : {}),
186
+ ...(candidate.ariaBusy ? { ariaBusy: candidate.ariaBusy } : {}),
187
+ }));
188
+ }
189
+ function unsafeSkip(candidate) {
190
+ return {
191
+ reason: 'unsafe-label',
192
+ selector: candidate.selector,
193
+ label: candidate.label,
194
+ detail: 'label matched the built-in destructive-action guard',
195
+ };
196
+ }
197
+ async function tryCandidate(page, url, before, candidate, options, seenDiffs) {
198
+ await page.goto(url, { waitUntil: 'load' });
199
+ const start = pathAndSearch(page.url());
200
+ try {
201
+ await perform(page, candidate);
202
+ const afterUrl = pathAndSearch(page.url());
203
+ if (afterUrl !== start) {
204
+ return {
205
+ skip: {
206
+ reason: 'navigated',
207
+ selector: candidate.selector,
208
+ label: candidate.label,
209
+ detail: `${start} -> ${afterUrl}`,
210
+ },
211
+ };
212
+ }
213
+ const after = await captureStyleMap(page, captureOptions(options));
214
+ const findings = diffStyleMaps(before, after);
215
+ if (!findings.length)
216
+ return {};
217
+ const hash = diffHash(findings);
218
+ if (seenDiffs.has(hash))
219
+ return {};
220
+ seenDiffs.add(hash);
221
+ return {
222
+ variant: {
223
+ key: variantKey(candidate),
224
+ action: candidate.action,
225
+ selector: candidate.selector,
226
+ reason: candidate.reason,
227
+ label: candidate.label,
228
+ findings: findings.length,
229
+ diffHash: hash,
230
+ ...(candidate.value ? { value: candidate.value } : {}),
231
+ },
232
+ };
233
+ }
234
+ catch (e) {
235
+ return {
236
+ skip: {
237
+ reason: 'action-failed',
238
+ selector: candidate.selector,
239
+ label: candidate.label,
240
+ detail: e instanceof Error ? e.message : String(e),
241
+ },
242
+ };
243
+ }
244
+ }
245
+ /**
246
+ * Discover one-step UI states by trying semantic controls and keeping only
247
+ * actions whose rendered computed-style map differs from the route baseline.
248
+ */
249
+ export async function harvestStyleVariants(page, options) {
250
+ const maxActions = options.maxActionsPerRoute ?? 40;
251
+ const routes = [];
252
+ for (const route of options.routes) {
253
+ const url = routeUrl(route, options.baseUrl);
254
+ await page.goto(url, { waitUntil: 'load' });
255
+ const before = await captureStyleMap(page, captureOptions(options));
256
+ const candidates = await discoverCandidates(page);
257
+ const liveStates = liveStatesFrom(before.liveCandidates);
258
+ const variants = [];
259
+ const skipped = [];
260
+ const seenDiffs = new Set();
261
+ for (const candidate of candidates.slice(0, maxActions)) {
262
+ if (candidate.reason === 'unsafe-label') {
263
+ skipped.push(unsafeSkip(candidate));
264
+ continue;
265
+ }
266
+ const result = await tryCandidate(page, url, before, candidate, options, seenDiffs);
267
+ if (result.variant)
268
+ variants.push(result.variant);
269
+ if (result.skip)
270
+ skipped.push(result.skip);
271
+ }
272
+ routes.push({ key: route.key, url: route.url, variants, liveStates, skipped });
273
+ }
274
+ return { routes };
275
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styleproof",
3
- "version": "3.1.4",
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",
@@ -39,7 +39,8 @@
39
39
  "styleproof-init": "./bin/styleproof-init.mjs",
40
40
  "styleproof-map": "./bin/styleproof-map.mjs",
41
41
  "styleproof-diff": "./bin/styleproof-diff.mjs",
42
- "styleproof-report": "./bin/styleproof-report.mjs"
42
+ "styleproof-report": "./bin/styleproof-report.mjs",
43
+ "styleproof-variants": "./bin/styleproof-variants.mjs"
43
44
  },
44
45
  "files": [
45
46
  "dist",
@@ -60,6 +61,7 @@
60
61
  "lint:fix": "eslint . --fix",
61
62
  "format": "prettier --write \"{src,bin,test,example,scripts}/**/*.{ts,mjs,js}\" \"*.{ts,js,mjs,json,md}\"",
62
63
  "format:check": "prettier --check \"{src,bin,test,example,scripts}/**/*.{ts,mjs,js}\" \"*.{ts,js,mjs,json,md}\"",
64
+ "privacy:check": "node scripts/privacy-check.mjs",
63
65
  "test": "npm run build && node --test test/*.test.mjs",
64
66
  "test:watch": "node --test --watch test/*.test.mjs",
65
67
  "test:e2e": "npm run build && playwright test",
@@ -68,7 +70,7 @@
68
70
  "demo:check": "npm run build && node scripts/demo-report.mjs --check",
69
71
  "init": "node ./bin/styleproof-init.mjs",
70
72
  "prepare": "npm run build && husky",
71
- "prepublishOnly": "npm run clean && npm run build && npm run typecheck && npm run lint && npm run format:check && npm test && npm run test:e2e"
73
+ "prepublishOnly": "npm run clean && npm run build && npm run typecheck && npm run lint && npm run format:check && npm run privacy:check && npm test && npm run test:e2e"
72
74
  },
73
75
  "peerDependencies": {
74
76
  "@playwright/test": ">=1.40"