styleproof 3.19.0 → 3.21.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 +131 -0
- package/README.md +20 -5
- package/bin/styleproof-diff.mjs +97 -22
- package/bin/styleproof-init.mjs +49 -20
- package/dist/capture.d.ts +8 -0
- package/dist/capture.js +22 -0
- package/dist/change-groups.d.ts +91 -0
- package/dist/change-groups.js +471 -0
- package/dist/cli-errors.js +7 -2
- package/dist/crawl.js +16 -4
- package/dist/diff.js +35 -0
- package/dist/report.d.ts +1 -4
- package/dist/report.js +70 -352
- package/dist/runner.js +109 -29
- package/package.json +2 -2
package/dist/runner.js
CHANGED
|
@@ -104,7 +104,7 @@ export function resolvePopupCaptureOptions(input) {
|
|
|
104
104
|
};
|
|
105
105
|
}
|
|
106
106
|
async function popupDomSnapshot(page, options) {
|
|
107
|
-
return page.evaluate(({ popupSelector, triggerSelector, attr, max = 0 }) => {
|
|
107
|
+
return page.evaluate(({ popupSelector, triggerSelector, attr, max = 0, relocatePath, relocateLabel }) => {
|
|
108
108
|
const qsa = (sel) => {
|
|
109
109
|
try {
|
|
110
110
|
return [...document.querySelectorAll(sel)];
|
|
@@ -133,6 +133,13 @@ async function popupDomSnapshot(page, options) {
|
|
|
133
133
|
}
|
|
134
134
|
return parts.join(' > ');
|
|
135
135
|
};
|
|
136
|
+
// The trigger's accessible name — mirrors crawl-surfaces' labelFor (aria-label
|
|
137
|
+
// || name || textContent || title). `path` is positional for an id-less trigger;
|
|
138
|
+
// this pins identity so a shifted same-tag sibling can't silently steal the bind.
|
|
139
|
+
const labelOf = (el) => (el.getAttribute('aria-label') || el.getAttribute('name') || el.textContent || el.getAttribute('title') || '')
|
|
140
|
+
.replace(/\s+/g, ' ')
|
|
141
|
+
.trim()
|
|
142
|
+
.slice(0, 80) || el.tagName.toLowerCase();
|
|
136
143
|
const popupKey = (el) => {
|
|
137
144
|
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
138
145
|
return [
|
|
@@ -150,7 +157,15 @@ async function popupDomSnapshot(page, options) {
|
|
|
150
157
|
const popups = qsa(popupSelector).filter(visible);
|
|
151
158
|
const keys = popups.map(popupKey);
|
|
152
159
|
if (!triggerSelector || !attr)
|
|
153
|
-
return { keys,
|
|
160
|
+
return { keys, candidates: [], found: false };
|
|
161
|
+
for (const el of qsa(`[${attr}]`))
|
|
162
|
+
el.removeAttribute(attr);
|
|
163
|
+
if (relocatePath) {
|
|
164
|
+
const target = qsa(triggerSelector).find((el) => pathOf(el) === relocatePath && labelOf(el) === relocateLabel);
|
|
165
|
+
if (target)
|
|
166
|
+
target.setAttribute(attr, 'target');
|
|
167
|
+
return { keys, candidates: [], found: Boolean(target) };
|
|
168
|
+
}
|
|
154
169
|
const safeTrigger = (el) => {
|
|
155
170
|
const tag = el.tagName.toLowerCase();
|
|
156
171
|
if (el.matches(':disabled, [aria-disabled="true"]'))
|
|
@@ -161,24 +176,30 @@ async function popupDomSnapshot(page, options) {
|
|
|
161
176
|
return el.getAttribute('href')?.startsWith('#') ?? false;
|
|
162
177
|
return true;
|
|
163
178
|
};
|
|
164
|
-
|
|
165
|
-
el.
|
|
166
|
-
|
|
167
|
-
candidates.
|
|
168
|
-
return {
|
|
179
|
+
const candidates = qsa(triggerSelector)
|
|
180
|
+
.filter((el) => visible(el) && !popups.some((popup) => popup !== el && popup.contains(el)) && safeTrigger(el))
|
|
181
|
+
.slice(0, max);
|
|
182
|
+
candidates.forEach((el, index) => el.setAttribute(attr, String(index)));
|
|
183
|
+
return {
|
|
184
|
+
keys,
|
|
185
|
+
candidates: candidates.map((el, index) => ({ index, path: pathOf(el), label: labelOf(el) })),
|
|
186
|
+
found: false,
|
|
187
|
+
};
|
|
169
188
|
}, options);
|
|
170
189
|
}
|
|
171
190
|
async function visiblePopupKeys(page, selector) {
|
|
172
191
|
return (await popupDomSnapshot(page, { popupSelector: selector })).keys;
|
|
173
192
|
}
|
|
193
|
+
/** Enumerate + mark the surface's popup triggers ONCE, and record the pristine
|
|
194
|
+
* overlay keys in the same DOM snapshot — the reset baseline every reopen is
|
|
195
|
+
* verified against. */
|
|
174
196
|
async function markPopupCandidates(page, options) {
|
|
175
|
-
|
|
197
|
+
return popupDomSnapshot(page, {
|
|
176
198
|
triggerSelector: options.triggers,
|
|
177
199
|
popupSelector: options.overlays,
|
|
178
200
|
attr: POPUP_TRIGGER_ATTR,
|
|
179
201
|
max: options.max,
|
|
180
202
|
});
|
|
181
|
-
return snapshot.indexes.map((index) => ({ index }));
|
|
182
203
|
}
|
|
183
204
|
function expandOne(surface, variant, variantKind) {
|
|
184
205
|
return {
|
|
@@ -308,16 +329,37 @@ async function assertDeterministic(page, surface, first, captureText, pending) {
|
|
|
308
329
|
throw new Error(selfCheckErrorMessage(surface.key, drift, [...new Set([...(first.volatile ?? []), ...(again.volatile ?? [])])], liveCandidates));
|
|
309
330
|
}
|
|
310
331
|
}
|
|
311
|
-
|
|
332
|
+
/**
|
|
333
|
+
* Reset the surface (Escape + `go()`), then open ONE candidate's popup.
|
|
334
|
+
*
|
|
335
|
+
* Two guarantees the naive open loop lacked:
|
|
336
|
+
* - the reset is VERIFIED against the pristine overlay keys, not assumed —
|
|
337
|
+
* Escape is not a universal close, and a non-navigating `go()` clears nothing;
|
|
338
|
+
* - the trigger is re-bound by the (path, label) identity recorded at first
|
|
339
|
+
* enumeration, never by its position in a fresh enumeration (the trigger set can
|
|
340
|
+
* shift between opens and silently key the popup under a different trigger; the
|
|
341
|
+
* label pins identity where the path alone is still positional for id-less triggers).
|
|
342
|
+
* Either check failing is reported for the caller to skip loudly.
|
|
343
|
+
*/
|
|
344
|
+
async function openPopupCandidate(page, surface, width, height, options, candidate, pristine) {
|
|
312
345
|
await page.setViewportSize({ width, height });
|
|
313
346
|
await page.keyboard.press('Escape').catch(() => { });
|
|
314
347
|
await surface.go(page);
|
|
315
|
-
const
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
348
|
+
const snapshot = await popupDomSnapshot(page, {
|
|
349
|
+
popupSelector: options.overlays,
|
|
350
|
+
triggerSelector: options.triggers,
|
|
351
|
+
attr: POPUP_TRIGGER_ATTR,
|
|
352
|
+
relocatePath: candidate.path,
|
|
353
|
+
relocateLabel: candidate.label,
|
|
354
|
+
});
|
|
355
|
+
const leaked = snapshot.keys.filter((key) => !pristine.has(key));
|
|
356
|
+
if (leaked.length)
|
|
357
|
+
return { status: 'leaked', leaked };
|
|
358
|
+
if (!snapshot.found)
|
|
359
|
+
return { status: 'missing' };
|
|
360
|
+
const before = new Set(snapshot.keys);
|
|
319
361
|
await page
|
|
320
|
-
.locator(`[${POPUP_TRIGGER_ATTR}="
|
|
362
|
+
.locator(`[${POPUP_TRIGGER_ATTR}="target"]`)
|
|
321
363
|
.first()
|
|
322
364
|
.click({ timeout: Math.max(500, options.timeoutMs), noWaitAfter: true })
|
|
323
365
|
.catch(() => undefined);
|
|
@@ -325,10 +367,19 @@ async function openPopupCandidate(page, surface, width, height, options, candida
|
|
|
325
367
|
do {
|
|
326
368
|
const opened = (await visiblePopupKeys(page, options.overlays)).find((key) => !before.has(key));
|
|
327
369
|
if (opened)
|
|
328
|
-
return opened;
|
|
370
|
+
return { status: 'opened', key: opened };
|
|
329
371
|
await page.waitForTimeout(50);
|
|
330
372
|
} while (Date.now() < deadline);
|
|
331
|
-
return
|
|
373
|
+
return { status: 'none' };
|
|
374
|
+
}
|
|
375
|
+
/** A popup candidate skipped instead of captured wrong must be NAMED — a silent
|
|
376
|
+
* skip reads as "nothing to capture" when the truth is "couldn't capture safely". */
|
|
377
|
+
function warnPopupSkipped(surface, popupId, width, reason) {
|
|
378
|
+
// eslint-disable-next-line no-console
|
|
379
|
+
console.warn(`styleproof: skipped ${surface.key}-${popupId}@${width} — ${reason}`);
|
|
380
|
+
}
|
|
381
|
+
function leakedOverlaysDesc(leaked) {
|
|
382
|
+
return `overlay(s) the reset (Escape + go()) could not clear: ${leaked.join('; ')}`;
|
|
332
383
|
}
|
|
333
384
|
function popupMetadata(surface, popupId) {
|
|
334
385
|
return {
|
|
@@ -346,26 +397,52 @@ async function captureOpenedPopupMap(page, surface, s, pending, popupId) {
|
|
|
346
397
|
metadata: popupMetadata(surface, popupId),
|
|
347
398
|
});
|
|
348
399
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
400
|
+
/** Reopen the popup and throw on drift/no-reopen. Returns the leaked overlay keys
|
|
401
|
+
* instead when the popup itself defeats the reset (e.g. it IS a toast Escape can't
|
|
402
|
+
* dismiss) — the reopen can't run, so the caller discards the capture loudly
|
|
403
|
+
* rather than saving a map whose determinism was never proven. */
|
|
404
|
+
async function assertPopupDeterministic(page, surface, width, height, options, candidate, popupId, first, s, pending, pristine) {
|
|
405
|
+
const reopened = await openPopupCandidate(page, surface, width, height, options, candidate, pristine);
|
|
406
|
+
if (reopened.status === 'leaked')
|
|
407
|
+
return reopened.leaked;
|
|
408
|
+
if (reopened.status !== 'opened') {
|
|
409
|
+
throw new Error(`styleproof self-check failed: ${surface.key}-${popupId} popup did not reopen` +
|
|
410
|
+
(reopened.status === 'missing' ? ' (its trigger disappeared from the DOM or changed identity)' : ''));
|
|
411
|
+
}
|
|
353
412
|
const again = await captureOpenedPopupMap(page, surface, s, pending, popupId);
|
|
354
413
|
const drift = diffStyleMaps(first, again);
|
|
355
414
|
if (drift.length) {
|
|
356
415
|
throw new Error(selfCheckErrorMessage(`${surface.key}-${popupId}`, drift, first.volatile, first.liveCandidates));
|
|
357
416
|
}
|
|
417
|
+
return undefined;
|
|
358
418
|
}
|
|
359
|
-
async function capturePopupCandidate(page, surface, width, height, s, options, candidate) {
|
|
419
|
+
async function capturePopupCandidate(page, surface, width, height, s, options, candidate, pristine) {
|
|
360
420
|
const requests = trackInflightRequests(page);
|
|
421
|
+
const popupId = `popup-${String(candidate.index + 1).padStart(2, '0')}`;
|
|
361
422
|
try {
|
|
362
|
-
const
|
|
363
|
-
if (
|
|
423
|
+
const opened = await openPopupCandidate(page, surface, width, height, options, candidate, pristine);
|
|
424
|
+
if (opened.status === 'none')
|
|
364
425
|
return;
|
|
365
|
-
|
|
426
|
+
if (opened.status === 'leaked') {
|
|
427
|
+
warnPopupSkipped(surface, popupId, width, `${leakedOverlaysDesc(opened.leaked)} — capturing now would include the previous ` +
|
|
428
|
+
`popup's residue. Dismiss it in the surface's go(), or capture it as an explicit variant.`);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (opened.status === 'missing') {
|
|
432
|
+
warnPopupSkipped(surface, popupId, width, `its originally-enumerated trigger is no longer identifiable after the reset (Escape + go()) — ` +
|
|
433
|
+
`gone from the DOM, or a shifted same-tag sibling no longer matches its recorded label; ` +
|
|
434
|
+
`skipping rather than re-binding to a different trigger.`);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
366
437
|
const map = await captureOpenedPopupMap(page, surface, s, requests.pending, popupId);
|
|
367
|
-
if (s.selfCheck)
|
|
368
|
-
await assertPopupDeterministic(page, surface, width, height, options, candidate, popupId, map, s, requests.pending);
|
|
438
|
+
if (s.selfCheck) {
|
|
439
|
+
const leaked = await assertPopupDeterministic(page, surface, width, height, options, candidate, popupId, map, s, requests.pending, pristine);
|
|
440
|
+
if (leaked) {
|
|
441
|
+
warnPopupSkipped(surface, popupId, width, `reopening for the self-check found ${leakedOverlaysDesc(leaked)} — the popup itself ` +
|
|
442
|
+
`defeats the reset, so its determinism can't be verified and the capture is discarded.`);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
369
446
|
const stem = path.join(s.baseDir, s.dir, `${surface.key}-${popupId}@${width}`);
|
|
370
447
|
saveStyleMap(`${stem}.json.gz`, map);
|
|
371
448
|
if (s.screenshots)
|
|
@@ -380,9 +457,12 @@ async function capturePopupSurfaces(page, surface, width, height, s) {
|
|
|
380
457
|
if (!options.enabled || options.max === 0)
|
|
381
458
|
return;
|
|
382
459
|
await surface.go(page);
|
|
383
|
-
const candidates = await markPopupCandidates(page, options);
|
|
460
|
+
const { keys, candidates } = await markPopupCandidates(page, options);
|
|
461
|
+
// Overlays legitimately visible in the surface's settled state (e.g. a permanent
|
|
462
|
+
// status region) — every reopen is verified back to this baseline before capture.
|
|
463
|
+
const pristine = new Set(keys);
|
|
384
464
|
for (const candidate of candidates) {
|
|
385
|
-
await capturePopupCandidate(page, surface, width, height, s, options, candidate);
|
|
465
|
+
await capturePopupCandidate(page, surface, width, height, s, options, candidate, pristine);
|
|
386
466
|
}
|
|
387
467
|
}
|
|
388
468
|
/** Drive one surface at one width to a settled state and save its style map (+ screenshot).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "styleproof",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.21.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",
|
|
@@ -90,6 +90,6 @@
|
|
|
90
90
|
"typescript": "^6.0.3",
|
|
91
91
|
"typescript-eslint": "^8.18.0",
|
|
92
92
|
"husky": "^9.1.7",
|
|
93
|
-
"fallow": "^2.
|
|
93
|
+
"fallow": "^3.2.0"
|
|
94
94
|
}
|
|
95
95
|
}
|