@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.
- package/CHANGELOG.md +14 -1
- package/dist/cli.js +201 -19
- 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
|
|
50536
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
50727
|
+
dismissals.push({ reason: "selector-match", tag: sel, method: "escape", dismissed: true });
|
|
50563
50728
|
} catch {}
|
|
50564
50729
|
}
|
|
50565
|
-
if (
|
|
50566
|
-
dlog2(ctx,
|
|
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
|
|
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.
|
|
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",
|