@decocms/parity 0.14.0 → 0.15.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.
Files changed (3) hide show
  1. package/CHANGELOG.md +14 -1
  2. package/dist/cli.js +201 -19
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,11 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.15.0](https://github.com/decocms/parity/compare/v0.14.0...v0.15.0) (2026-07-27)
11
+
12
+ ### Added
13
+
14
+ * **Structural overlay interception detection + configurable overlay selectors (issues #145, #146).** A "no-signal" add-to-cart failure is often not a broken cart but a newsletter/discount modal intercepting the click — classically one that pops on the very `mousemove` Playwright's `.click()` dispatches, in a cookie-less session (e.g. a `div.fixed bg-black/50 z-9999` backdrop over the buy button). Instead of name-matching every popup shape (whack-a-mole), parity now detects interception **structurally**: before retrying, it checks whether the buy button is still the topmost element at its click point (`document.elementFromPoint`) and, if something covers it, dismisses it least-destructive-first — Escape → a named close button → an icon/geometry close control inside the overlay (catches unnamed `<button><svg/></button>` X's) → a corner backdrop click → and, guarded, hiding the confirmed interceptor as a last resort (never an ancestor of the target, and it can't fake success — the step still needs a real confirmation signal) — then retries once. What was detected/dismissed is recorded in the step's `detail.overlayDismissed` and surfaced in the action text, so a report shows *why* a click was intercepted even when dismissal succeeded. Separately, `.parityrc.json` `overlaySelectors` adds explicit site-specific overlay selectors, merged with the built-in cookie/toast/newsletter defaults (never replacing them), as a fast-path override.
15
+
16
+ ## [0.14.0](https://github.com/decocms/parity/compare/v0.13.0...v0.14.0) (2026-07-27)
17
+
18
+ ### Added
19
+
20
+ * **Configurable add-to-cart confirmation deadline (issue #143).** The purchase-journey / `e2e` add-to-cart step polled a hardcoded 3000ms for a success signal (URL→cart, minicart count increase, drawer open, success toast). On sites whose success toast is short-lived, or with slow TTFB / popup overlays, that could race the deadline and report a false "no signal" failure on an add-to-cart that actually worked. Now tunable via `.parityrc.json` `addToCartConfirmMs` or `--add-to-cart-timeout <ms>` (on `parity run` and `parity e2e`); a non-positive/NaN override is ignored and falls back to the 3000ms default.
21
+
22
+ ## [0.13.0](https://github.com/decocms/parity/compare/v0.12.0...v0.13.0) (2026-07-27)
23
+
10
24
  ### Added
11
25
 
12
26
  * **`parity e2e` now automates selectors like `parity run` (issue #141).** Single-site runs previously saw only `DEFAULT_SELECTORS` + hand-written `.parityrc.json` — `e2e` never detected the platform, never ran LLM selector discovery, and never learned from its flow runs (so `learned-selectors.json`, keyed by platform, never applied). It now detects the platform, runs the same grounded LLM discovery + live-validation pass, threads the platform into every flow, and promotes selectors learned from real successful interactions. New flags mirror `run`: `--no-auto-selectors`, `--refresh-selectors`, `--no-learn`. The discovery pass is now shared code (`src/engine/selector-discovery-pass.ts`) used by both commands.
13
27
  * **Discovery covers the journey variant/quantity keys.** LLM selector discovery now infers `variantRow`, `quantityIncrement`, `quantityInput`, `sizeSwatch`, and `colorSwatch` (grounded on the real PDP and live-validated) — exactly the keys single-site users kept hand-writing in `.parityrc.json`.
14
- * **Configurable add-to-cart confirmation deadline (issue #143).** The purchase-journey / `e2e` add-to-cart step polled a hardcoded 3000ms for a success signal (URL→cart, minicart count increase, drawer open, success toast). On sites whose success toast is short-lived, or with slow TTFB / popup overlays, that could race the deadline and report a false "no signal" failure on an add-to-cart that actually worked. Now tunable via `.parityrc.json` `addToCartConfirmMs` or `--add-to-cart-timeout <ms>` (on `parity run` and `parity e2e`); a non-positive/NaN override is ignored and falls back to the 3000ms default.
15
28
 
16
29
  ### Fixed
17
30
 
package/dist/cli.js CHANGED
@@ -41434,6 +41434,7 @@ var ParityRc = z.object({
41434
41434
  }).optional(),
41435
41435
  serverFnFloodBudget: z.number().optional(),
41436
41436
  serverFnPattern: z.string().optional(),
41437
+ overlaySelectors: z.array(z.string()).optional(),
41437
41438
  addToCartConfirmMs: z.number().optional()
41438
41439
  });
41439
41440
  var ParityIgnore = z.object({
@@ -50531,41 +50532,206 @@ async function extractProductTitle(page) {
50531
50532
  return docTitle.trim();
50532
50533
  return null;
50533
50534
  }
50535
+ var DEFAULT_OVERLAY_SELECTORS = [
50536
+ "[class*='cookie' i][class*='banner' i]",
50537
+ "[class*='cookie' i][class*='consent' i]",
50538
+ "[id*='cookie' i][class*='banner' i]",
50539
+ "[role='alertdialog']:visible",
50540
+ "[class*='toast' i]:visible",
50541
+ "[class*='snackbar' i]:visible",
50542
+ "[class*='added-to-cart' i]:visible",
50543
+ "[class*='product-added' i]:visible",
50544
+ "[id*='newsletter' i]:visible",
50545
+ "[class*='newsletter' i]:visible",
50546
+ "[id*='signup-popup' i]:visible",
50547
+ "[class*='signup' i][class*='popup' i]:visible"
50548
+ ];
50549
+ var CLOSE_BUTTON_SELECTOR = "button[aria-label*='close' i], [aria-label*='close' i], button[aria-label*='fechar' i], [aria-label*='fechar' i], [data-close], [data-dismiss], [data-qa-dismiss], button[class*='close' i], button:has-text('×'), button:has-text('✕')";
50550
+ function overlaySelectorsFor(rc) {
50551
+ const extra = rc?.overlaySelectors ?? [];
50552
+ return [...new Set([...DEFAULT_OVERLAY_SELECTORS, ...extra])];
50553
+ }
50554
+ async function blockerAtTarget(target) {
50555
+ return await withCap(target.evaluate((el) => {
50556
+ const node2 = el;
50557
+ const rect = node2.getBoundingClientRect();
50558
+ if (rect.width === 0 || rect.height === 0)
50559
+ return null;
50560
+ const cx = rect.left + rect.width / 2;
50561
+ const cy = rect.top + rect.height / 2;
50562
+ const top = document.elementFromPoint(cx, cy);
50563
+ if (!top || top === node2 || node2.contains(top))
50564
+ return null;
50565
+ let cur = top;
50566
+ let overlay = top;
50567
+ while (cur && cur !== document.body) {
50568
+ const pos = getComputedStyle(cur).position;
50569
+ if (pos === "fixed" || pos === "sticky" || pos === "absolute")
50570
+ overlay = cur;
50571
+ cur = cur.parentElement;
50572
+ }
50573
+ const r = overlay.getBoundingClientRect();
50574
+ const coverage = r.width * r.height / (window.innerWidth * window.innerHeight);
50575
+ const cls = typeof overlay.className === "string" ? overlay.className : "";
50576
+ return {
50577
+ tag: overlay.tagName.toLowerCase(),
50578
+ id: overlay.id || undefined,
50579
+ className: cls ? cls.slice(0, 120) : undefined,
50580
+ fullViewport: coverage > 0.6
50581
+ };
50582
+ }).catch(() => null), 2000, null);
50583
+ }
50584
+ async function findOverlayCloseControl(target) {
50585
+ return await withCap(target.evaluate((el) => {
50586
+ const node2 = el;
50587
+ const rect = node2.getBoundingClientRect();
50588
+ if (rect.width === 0 || rect.height === 0)
50589
+ return null;
50590
+ const cx = rect.left + rect.width / 2;
50591
+ const cy = rect.top + rect.height / 2;
50592
+ const top = document.elementFromPoint(cx, cy);
50593
+ if (!top || top === node2 || node2.contains(top))
50594
+ return null;
50595
+ let cur = top;
50596
+ let overlay = top;
50597
+ while (cur && cur !== document.body) {
50598
+ const pos = getComputedStyle(cur).position;
50599
+ if (pos === "fixed" || pos === "sticky" || pos === "absolute")
50600
+ overlay = cur;
50601
+ cur = cur.parentElement;
50602
+ }
50603
+ const orect = overlay.getBoundingClientRect();
50604
+ const clickables = Array.from(overlay.querySelectorAll("button, a, [role='button']"));
50605
+ let best = null;
50606
+ for (const c of clickables) {
50607
+ const r = c.getBoundingClientRect();
50608
+ if (r.width === 0 || r.height === 0 || r.width > 72 || r.height > 72)
50609
+ continue;
50610
+ const label = `${c.getAttribute("aria-label") ?? ""} ${typeof c.className === "string" ? c.className : ""} ${c.textContent ?? ""}`.toLowerCase();
50611
+ const named = /close|fechar|dismiss|×|✕/.test(label);
50612
+ const distTopRight = Math.hypot(orect.right - r.right, r.top - orect.top);
50613
+ const score = (named ? 0 : 1e5) + distTopRight;
50614
+ if (!best || score < best.score) {
50615
+ best = { x: r.left + r.width / 2, y: r.top + r.height / 2, score };
50616
+ }
50617
+ }
50618
+ return best ? { x: best.x, y: best.y } : null;
50619
+ }).catch(() => null), 2000, null);
50620
+ }
50621
+ async function neutralizeBlockerAtTarget(target) {
50622
+ return await withCap(target.evaluate((el) => {
50623
+ const node2 = el;
50624
+ const rect = node2.getBoundingClientRect();
50625
+ if (rect.width === 0 || rect.height === 0)
50626
+ return false;
50627
+ const cx = rect.left + rect.width / 2;
50628
+ const cy = rect.top + rect.height / 2;
50629
+ const top = document.elementFromPoint(cx, cy);
50630
+ if (!top || top === node2 || node2.contains(top))
50631
+ return false;
50632
+ let cur = top;
50633
+ let overlay = top;
50634
+ while (cur && cur !== document.body) {
50635
+ const pos = getComputedStyle(cur).position;
50636
+ if (pos === "fixed" || pos === "sticky" || pos === "absolute")
50637
+ overlay = cur;
50638
+ cur = cur.parentElement;
50639
+ }
50640
+ if (overlay.contains(node2))
50641
+ return false;
50642
+ overlay.style.setProperty("display", "none", "important");
50643
+ return true;
50644
+ }).catch(() => false), 2000, false);
50645
+ }
50646
+ async function dismissBlockingOverlay(page, ctx, target) {
50647
+ const blocker = await blockerAtTarget(target);
50648
+ if (!blocker)
50649
+ return null;
50650
+ const base = {
50651
+ reason: "click-point-intercepted",
50652
+ tag: blocker.tag,
50653
+ id: blocker.id,
50654
+ className: blocker.className,
50655
+ fullViewport: blocker.fullViewport
50656
+ };
50657
+ const cleared = async () => await blockerAtTarget(target) === null;
50658
+ await page.keyboard.press("Escape").catch(() => {
50659
+ return;
50660
+ });
50661
+ await page.waitForTimeout(300);
50662
+ if (await cleared()) {
50663
+ dlog2(ctx, ` overlay: dismissed via Escape (${describe(blocker)})`);
50664
+ return { ...base, method: "escape", dismissed: true };
50665
+ }
50666
+ const closer = page.locator(CLOSE_BUTTON_SELECTOR).first();
50667
+ if (await withCap(closer.isVisible({ timeout: 300 }).catch(() => false), 500, false)) {
50668
+ await closer.click({ timeout: 1500 }).catch(() => {
50669
+ return;
50670
+ });
50671
+ await page.waitForTimeout(300);
50672
+ if (await cleared()) {
50673
+ dlog2(ctx, ` overlay: dismissed via close button (${describe(blocker)})`);
50674
+ return { ...base, method: "close-button", dismissed: true };
50675
+ }
50676
+ }
50677
+ const closeXY = await findOverlayCloseControl(target);
50678
+ if (closeXY) {
50679
+ await page.mouse.click(closeXY.x, closeXY.y).catch(() => {
50680
+ return;
50681
+ });
50682
+ await page.waitForTimeout(300);
50683
+ if (await cleared()) {
50684
+ dlog2(ctx, ` overlay: dismissed via icon close control (${describe(blocker)})`);
50685
+ return { ...base, method: "close-button", dismissed: true };
50686
+ }
50687
+ }
50688
+ await page.mouse.click(5, 5).catch(() => {
50689
+ return;
50690
+ });
50691
+ await page.waitForTimeout(300);
50692
+ if (await cleared()) {
50693
+ dlog2(ctx, ` overlay: dismissed via backdrop click (${describe(blocker)})`);
50694
+ return { ...base, method: "backdrop-click", dismissed: true };
50695
+ }
50696
+ if (await neutralizeBlockerAtTarget(target)) {
50697
+ await page.waitForTimeout(150);
50698
+ if (await cleared()) {
50699
+ dlog2(ctx, ` overlay: neutralized (hidden) as last resort (${describe(blocker)})`);
50700
+ return { ...base, method: "neutralized", dismissed: true };
50701
+ }
50702
+ }
50703
+ dlog2(ctx, ` overlay: detected but NOT dismissed (${describe(blocker)})`);
50704
+ return { ...base, method: "backdrop-click", dismissed: false };
50705
+ }
50706
+ function describe(b) {
50707
+ return `${b.tag}${b.id ? `#${b.id}` : ""}${b.className ? `.${b.className.split(/\s+/)[0]}` : ""}`;
50708
+ }
50534
50709
  async function dismissOverlays(page, ctx) {
50535
- const overlaySelectors = [
50536
- "[class*='cookie' i][class*='banner' i]",
50537
- "[class*='cookie' i][class*='consent' i]",
50538
- "[id*='cookie' i][class*='banner' i]",
50539
- "[role='alertdialog']:visible",
50540
- "[class*='toast' i]:visible",
50541
- "[class*='snackbar' i]:visible",
50542
- "[class*='added-to-cart' i]:visible",
50543
- "[class*='product-added' i]:visible"
50544
- ];
50545
- let dismissedAny = false;
50546
- for (const sel of overlaySelectors) {
50710
+ const dismissals = [];
50711
+ for (const sel of overlaySelectorsFor(ctx.rc)) {
50547
50712
  try {
50548
50713
  const overlay = page.locator(sel).first();
50549
50714
  if (!await withCap(overlay.isVisible({ timeout: 200 }).catch(() => false), 400, false))
50550
50715
  continue;
50551
- const closer = overlay.locator("button[aria-label*='close' i], button[aria-label*='fechar' i], button[class*='close' i], [data-close], [aria-label='Close']").first();
50716
+ const closer = overlay.locator(CLOSE_BUTTON_SELECTOR).first();
50552
50717
  if (await withCap(closer.isVisible({ timeout: 200 }).catch(() => false), 400, false)) {
50553
50718
  await closer.click({ timeout: 1500 }).catch(() => {
50554
50719
  return;
50555
50720
  });
50556
- dismissedAny = true;
50721
+ dismissals.push({ reason: "selector-match", tag: sel, method: "close-button", dismissed: true });
50557
50722
  continue;
50558
50723
  }
50559
50724
  await page.keyboard.press("Escape").catch(() => {
50560
50725
  return;
50561
50726
  });
50562
- dismissedAny = true;
50727
+ dismissals.push({ reason: "selector-match", tag: sel, method: "escape", dismissed: true });
50563
50728
  } catch {}
50564
50729
  }
50565
- if (dismissedAny) {
50566
- dlog2(ctx, " openMinicart: dismissed overlay(s) before interacting");
50730
+ if (dismissals.length > 0) {
50731
+ dlog2(ctx, ` dismissed ${dismissals.length} overlay(s) before interacting`);
50567
50732
  await page.waitForTimeout(500);
50568
50733
  }
50734
+ return dismissals;
50569
50735
  }
50570
50736
 
50571
50737
  // src/engine/flows/cart-helpers.ts
@@ -51631,10 +51797,25 @@ async function flowPurchaseJourney(ctx) {
51631
51797
  validation = await validateAddToCart(page, ctx, cartCountBefore, beforeUrl6);
51632
51798
  }
51633
51799
  }
51800
+ let overlayDismissed;
51801
+ if (validation.status === "failed" && validation.signal === "no-signal") {
51802
+ const od = await dismissBlockingOverlay(page, ctx, buyHit.locator);
51803
+ if (od) {
51804
+ overlayDismissed = od;
51805
+ if (od.dismissed) {
51806
+ await page.waitForTimeout(300);
51807
+ await buyHit.locator.click({ timeout: 5000 }).catch(() => {
51808
+ return;
51809
+ });
51810
+ validation = await validateAddToCart(page, ctx, cartCountBefore, beforeUrl6);
51811
+ }
51812
+ }
51813
+ }
51634
51814
  const sp6 = screenshotPath(ctx, "pj-6-add-cart");
51635
51815
  await screenshotStable(page, { path: sp6, fullPage: false });
51636
51816
  const buyActionDesc = `Clicou no botão${buyText ? ` '${buyText.slice(0, 40).trim()}'` : ""} (\`${buyHit.selector}\`)${buyRecovered ? " — selector veio de recovery LLM" : ""}`;
51637
- const fullActionDesc = variantRetryNote ? `${buyActionDesc} — ${variantRetryNote} ${validation.note}` : `${buyActionDesc} ${validation.note}`;
51817
+ const overlayNote = overlayDismissed ? `overlay ${overlayDismissed.tag}${overlayDismissed.id ? `#${overlayDismissed.id}` : ""} ${overlayDismissed.dismissed ? `dismissed (${overlayDismissed.method}), click retried` : "detected but NOT dismissed"}` : "";
51818
+ const fullActionDesc = variantRetryNote ? `${buyActionDesc} — ${variantRetryNote}${overlayNote} — ${validation.note}` : `${buyActionDesc}${overlayNote} — ${validation.note}`;
51638
51819
  steps.push({
51639
51820
  step: 6,
51640
51821
  name: "add-to-cart",
@@ -51654,7 +51835,8 @@ async function flowPurchaseJourney(ctx) {
51654
51835
  detail: {
51655
51836
  signal: validation.signal,
51656
51837
  errorText: validation.errorText,
51657
- variantRetry: variantRetryNote
51838
+ variantRetry: variantRetryNote,
51839
+ overlayDismissed
51658
51840
  }
51659
51841
  });
51660
51842
  reportEnd(6, "add-to-cart", validation.status, Date.now() - t6, validation.status === "ok" ? undefined : validation.note);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/parity",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "E2E parity validator for site migrations. Compares prod vs cand and reports UI, functional, SEO, visual, and Web Vitals deltas with an LLM-ranked HTML report.",
5
5
  "type": "module",
6
6
  "license": "MIT",