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/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, indexes: [] };
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
- for (const el of qsa(`[${attr}]`))
165
- el.removeAttribute(attr);
166
- const candidates = qsa(triggerSelector).filter((el) => visible(el) && !popups.some((popup) => popup !== el && popup.contains(el)) && safeTrigger(el));
167
- candidates.slice(0, max).forEach((el, index) => el.setAttribute(attr, String(index)));
168
- return { keys, indexes: candidates.slice(0, max).map((_, index) => index) };
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
- const snapshot = await popupDomSnapshot(page, {
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
- async function openPopupCandidate(page, surface, width, height, options, candidate) {
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 before = new Set(await visiblePopupKeys(page, options.overlays));
316
- const candidates = await markPopupCandidates(page, options);
317
- if (!candidates.some((c) => c.index === candidate.index))
318
- return undefined;
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}="${candidate.index}"]`)
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 undefined;
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
- async function assertPopupDeterministic(page, surface, width, height, options, candidate, popupId, first, s, pending) {
350
- const againKey = await openPopupCandidate(page, surface, width, height, options, candidate);
351
- if (!againKey)
352
- throw new Error(`styleproof self-check failed: ${surface.key}-${popupId} popup did not reopen`);
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 popupKey = await openPopupCandidate(page, surface, width, height, options, candidate);
363
- if (!popupKey)
423
+ const opened = await openPopupCandidate(page, surface, width, height, options, candidate, pristine);
424
+ if (opened.status === 'none')
364
425
  return;
365
- const popupId = `popup-${String(candidate.index + 1).padStart(2, '0')}`;
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.19.0",
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.96.0"
93
+ "fallow": "^3.2.0"
94
94
  }
95
95
  }