@decocms/parity 0.11.4 → 0.11.6

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 +27 -0
  2. package/dist/cli.js +290 -16
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [0.11.6](https://github.com/decocms/parity/compare/v0.11.5...v0.11.6) (2026-06-17)
9
+
10
+ ### Added
11
+
12
+ * **New `plp-pagination` check.** Tests that `?page=2` and `?page=3` of every captured PLP return 200 AND show different products than page 1. Catches the classic migration regression: the new site silently ignores `?page=N` and returns the first page on every request (or caps pagination). Live tested against bagaggio TanStack — surfaces a real critical bug (`page=2` shows the same 10 products as `page=1`, 100% overlap). Falls back to scraping the home page for a category link when running standalone (`parity check plp-pagination`).
13
+ * **Cross-side count divergence detection.** When prod and cand both serve a paginated PLP but cand's `?page=2` product count differs from prod's by more than 30%, flag as medium-severity (likely sort-order or index pruning regression).
14
+
15
+ ## [0.11.5](https://github.com/decocms/parity/compare/v0.11.4...v0.11.5) (2026-06-17)
16
+
17
+ ### Fixed
18
+
19
+ * **Variant clicks now wait for navigation.** Deco TanStack PDPs render size/color pickers as `<a href="<product>/p?skuId=N">` links — clicking navigates to a different SKU URL. The previous `click + waitForTimeout(400)` ran add-to-cart against the pre-nav page where the variant was still "unselected". New `clickAndMaybeWait` helper races the click with `page.waitForNavigation` so the flow runs against the post-navigation page when it happens, and falls through when it doesn't (button-radio case).
20
+ * **`validateAddToCart` now uses `cartOpenedIndicator` selectors.** The new selector key from 0.11.4 (`[aria-label='Fechar notificação']` / `[aria-label='Fechar carrinho']` + generic role fallbacks) is now actually wired into the post-click drawer-open probe, so Deco TanStack notifications correctly mark add-to-cart as `ok` via the `drawer-open` signal.
21
+
22
+ ### Added
23
+
24
+ * **Landing-page detection before LLM recovery on missing buy button.** When step 6 (add-to-cart) can't find a buy button, the runner now checks whether the page even looks like a PDP (schema:Product JSON-LD, `<form>` with button, price text, variant inputs). If fewer than 2 PDP signals fire, the step is skipped with `PDP appears to be a landing page (...)` instead of burning LLM recovery budget on a page that has nothing to recover. Reasons are surfaced in the skip note so the user immediately knows why.
25
+
26
+ ### Verification (live against bagaggio)
27
+
28
+ | Version | Steps reached on cand |
29
+ | --- | --- |
30
+ | 0.11.2 and earlier | 3/9 (stops at enter-pdp) |
31
+ | 0.11.3 | 4/9 (stops at select-variant) |
32
+ | 0.11.4 | 6/9 (stops at add-to-cart) |
33
+ | **0.11.5** | **9/9** (reaches go-checkout) |
34
+
8
35
  ## [0.11.4](https://github.com/decocms/parity/compare/v0.11.3...v0.11.4) (2026-06-17)
9
36
 
10
37
  ### Added
package/dist/cli.js CHANGED
@@ -8534,6 +8534,225 @@ function hasReservedSpace($img) {
8534
8534
  return false;
8535
8535
  }
8536
8536
 
8537
+ // src/checks/plp-pagination.ts
8538
+ async function plpPagination(ctx) {
8539
+ const start = Date.now();
8540
+ const issues = [];
8541
+ let prodPlp = pickPlpUrl(ctx.prodFlows);
8542
+ let candPlp = pickPlpUrl(ctx.candFlows);
8543
+ if (!prodPlp && ctx.prodPages.length > 0) {
8544
+ prodPlp = await discoverPlpFromHome(ctx.prodPages[0].url);
8545
+ }
8546
+ if (!candPlp && ctx.candPages.length > 0) {
8547
+ candPlp = await discoverPlpFromHome(ctx.candPages[0].url);
8548
+ }
8549
+ if (!prodPlp && !candPlp) {
8550
+ return {
8551
+ name: "plp-pagination",
8552
+ status: "skipped",
8553
+ severity: "medium",
8554
+ durationMs: Date.now() - start,
8555
+ summary: "no PLP captured by purchase-journey step 2 AND no home page to discover one from — skipping",
8556
+ issues: []
8557
+ };
8558
+ }
8559
+ const data = {};
8560
+ for (const [side, plp] of [
8561
+ ["prod", prodPlp],
8562
+ ["cand", candPlp]
8563
+ ]) {
8564
+ if (!plp)
8565
+ continue;
8566
+ const page1 = await fetchPlpProducts(plp);
8567
+ const page2 = await fetchPlpProducts(withPage(plp, 2));
8568
+ const page3 = await fetchPlpProducts(withPage(plp, 3));
8569
+ data[`${side}_page_counts`] = {
8570
+ page1: { status: page1.status, products: page1.productPaths.length },
8571
+ page2: { status: page2.status, products: page2.productPaths.length },
8572
+ page3: { status: page3.status, products: page3.productPaths.length }
8573
+ };
8574
+ for (const [n, result] of [
8575
+ [2, page2],
8576
+ [3, page3]
8577
+ ]) {
8578
+ if (result.status !== 200) {
8579
+ issues.push({
8580
+ id: `plp-pagination:${side}:page${n}:status-${result.status}`,
8581
+ severity: "high",
8582
+ category: "functional",
8583
+ check: "plp-pagination",
8584
+ summary: `[${side}] page=${n} returned HTTP ${result.status} (expected 200) — pagination breaks under ?page=${n}`,
8585
+ page: withPage(plp, n)
8586
+ });
8587
+ }
8588
+ }
8589
+ if (page1.productPaths.length > 0 && page2.status === 200) {
8590
+ const overlap = setOverlap(page1.productPaths, page2.productPaths);
8591
+ if (overlap > 0.9) {
8592
+ issues.push({
8593
+ id: `plp-pagination:${side}:page2-identical`,
8594
+ severity: "critical",
8595
+ category: "functional",
8596
+ check: "plp-pagination",
8597
+ summary: `[${side}] page=2 shows the same ${page1.productPaths.length} products as page=1 (${(overlap * 100).toFixed(0)}% overlap) — ?page=N is being ignored`,
8598
+ page: withPage(plp, 2),
8599
+ details: `Page 1 first product: ${page1.productPaths[0] ?? "(none)"}
8600
+ Page 2 first product: ${page2.productPaths[0] ?? "(none)"}`
8601
+ });
8602
+ }
8603
+ }
8604
+ if (page2.productPaths.length > 0 && page3.status === 200) {
8605
+ const overlap = setOverlap(page2.productPaths, page3.productPaths);
8606
+ if (overlap > 0.9) {
8607
+ issues.push({
8608
+ id: `plp-pagination:${side}:page3-identical`,
8609
+ severity: "critical",
8610
+ category: "functional",
8611
+ check: "plp-pagination",
8612
+ summary: `[${side}] page=3 shows the same products as page=2 (${(overlap * 100).toFixed(0)}% overlap) — pagination capped or broken`,
8613
+ page: withPage(plp, 3)
8614
+ });
8615
+ }
8616
+ }
8617
+ }
8618
+ if (prodPlp && candPlp) {
8619
+ const prodCounts = data.prod_page_counts;
8620
+ const candCounts = data.cand_page_counts;
8621
+ if (prodCounts && candCounts) {
8622
+ const p = prodCounts.page2.products;
8623
+ const c = candCounts.page2.products;
8624
+ if (p > 0 && Math.abs(p - c) / p > 0.3) {
8625
+ issues.push({
8626
+ id: "plp-pagination:cross-side-count-divergence",
8627
+ severity: "medium",
8628
+ category: "functional",
8629
+ check: "plp-pagination",
8630
+ summary: `page=2 product count diverges: prod=${p}, cand=${c} (Δ ${Math.abs(p - c)}, >30%)`
8631
+ });
8632
+ }
8633
+ }
8634
+ }
8635
+ const status = issues.some((i) => i.severity === "critical") ? "fail" : issues.length > 0 ? "fail" : "pass";
8636
+ return {
8637
+ name: "plp-pagination",
8638
+ status,
8639
+ severity: "high",
8640
+ durationMs: Date.now() - start,
8641
+ summary: issues.length === 0 ? `PLP pagination working on both sides (tested page=2 + page=3)` : `${issues.length} pagination issue(s) — see details`,
8642
+ issues,
8643
+ data
8644
+ };
8645
+ }
8646
+ async function fetchPlpProducts(url) {
8647
+ try {
8648
+ const res = await fetch(url, {
8649
+ headers: {
8650
+ "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
8651
+ },
8652
+ signal: AbortSignal.timeout(15000)
8653
+ });
8654
+ if (res.status !== 200) {
8655
+ return { status: res.status, productPaths: [] };
8656
+ }
8657
+ const html = await res.text();
8658
+ const paths = new Set;
8659
+ const re = /href="([^"]+\/p(?:\?[^"]*|\/[^"]+|))"/gi;
8660
+ let m;
8661
+ while (m = re.exec(html)) {
8662
+ const path = m[1];
8663
+ if (!path)
8664
+ continue;
8665
+ const normalized = path.split("?")[0].replace(/\/$/, "");
8666
+ paths.add(normalized);
8667
+ }
8668
+ const re2 = /href="([^"]+\/products\/[^"]+)"/gi;
8669
+ while (m = re2.exec(html)) {
8670
+ const path = m[1];
8671
+ if (path)
8672
+ paths.add(path.split("?")[0].replace(/\/$/, ""));
8673
+ }
8674
+ return { status: 200, productPaths: Array.from(paths) };
8675
+ } catch (err) {
8676
+ return { status: 0, productPaths: [] };
8677
+ }
8678
+ }
8679
+ function withPage(url, n) {
8680
+ try {
8681
+ const u = new URL(url);
8682
+ u.searchParams.set("page", String(n));
8683
+ return u.toString();
8684
+ } catch {
8685
+ const sep = url.includes("?") ? "&" : "?";
8686
+ return `${url}${sep}page=${n}`;
8687
+ }
8688
+ }
8689
+ async function discoverPlpFromHome(homeUrl) {
8690
+ try {
8691
+ const res = await fetch(homeUrl, {
8692
+ headers: {
8693
+ "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
8694
+ },
8695
+ signal: AbortSignal.timeout(15000)
8696
+ });
8697
+ if (!res.ok)
8698
+ return null;
8699
+ const html = await res.text();
8700
+ const candidates = new Set;
8701
+ const re = /href="([^"]+)"/gi;
8702
+ let m;
8703
+ while (m = re.exec(html)) {
8704
+ const href = m[1];
8705
+ if (!href)
8706
+ continue;
8707
+ if (/^https?:\/\//.test(href) || href.startsWith("#") || href === "/" || /\/(p|cart|carrinho|checkout|account|conta|login|wishlist|favoritos)(\/|$|\?)/i.test(href) || /\.(jpg|png|webp|css|js|svg|ico|woff)/i.test(href)) {
8708
+ continue;
8709
+ }
8710
+ const segments = href.split("?")[0].split("/").filter(Boolean);
8711
+ if (segments.length === 0 || segments.length > 3)
8712
+ continue;
8713
+ candidates.add(href.split("?")[0]);
8714
+ }
8715
+ const sorted = Array.from(candidates).sort((a, b) => a.length - b.length);
8716
+ const home = new URL(homeUrl);
8717
+ const pick = sorted[0];
8718
+ if (!pick)
8719
+ return null;
8720
+ return new URL(pick, home).toString();
8721
+ } catch {
8722
+ return null;
8723
+ }
8724
+ }
8725
+ function pickPlpUrl(flows) {
8726
+ for (const fc of flows) {
8727
+ if (fc.flow !== "purchase-journey")
8728
+ continue;
8729
+ const plpStep = fc.steps?.find((s) => s.name === "navigate-plp" && s.status === "ok");
8730
+ if (plpStep?.url)
8731
+ return plpStep.url;
8732
+ const plpPage = fc.pages.find((p) => {
8733
+ try {
8734
+ const u = new URL(p.url);
8735
+ return u.pathname.split("/").filter(Boolean).length <= 2 && !/\/p$|\/products\//.test(u.pathname);
8736
+ } catch {
8737
+ return false;
8738
+ }
8739
+ });
8740
+ if (plpPage)
8741
+ return plpPage.url;
8742
+ }
8743
+ return null;
8744
+ }
8745
+ function setOverlap(a, b) {
8746
+ if (a.length === 0 || b.length === 0)
8747
+ return 0;
8748
+ const sa = new Set(a);
8749
+ let common = 0;
8750
+ for (const x of b)
8751
+ if (sa.has(x))
8752
+ common++;
8753
+ return common / Math.max(a.length, b.length);
8754
+ }
8755
+
8537
8756
  // src/checks/purchase-journey-flow.ts
8538
8757
  var STEP_LABELS3 = {
8539
8758
  "visit-home": "Visitar home",
@@ -10515,7 +10734,8 @@ var ALL_CHECKS = [
10515
10734
  pdpGalleryRelated,
10516
10735
  footerLinksHealth,
10517
10736
  loginFlow,
10518
- pictureMissingDims
10737
+ pictureMissingDims,
10738
+ plpPagination
10519
10739
  ];
10520
10740
  var ALL_CHECKS_BY_NAME = {
10521
10741
  "http-status-parity": httpStatusParity,
@@ -10542,7 +10762,8 @@ var ALL_CHECKS_BY_NAME = {
10542
10762
  "pdp-gallery-related": pdpGalleryRelated,
10543
10763
  "footer-links-health": footerLinksHealth,
10544
10764
  "login-flow": loginFlow,
10545
- "picture-missing-dims": pictureMissingDims
10765
+ "picture-missing-dims": pictureMissingDims,
10766
+ "plp-pagination": plpPagination
10546
10767
  };
10547
10768
  var FLOW_DEPENDENT_CHECKS = new Set([
10548
10769
  "purchase-journey-flow",
@@ -12776,12 +12997,20 @@ async function flowPurchaseJourney(ctx) {
12776
12997
  reportStart(6, "add-to-cart");
12777
12998
  let buyHit = await firstVisibleLocator(page, selFor(ctx, "buyButton"));
12778
12999
  let buyRecovered = false;
12779
- if (!buyHit && budget.remaining > 0) {
12780
- const recovery = await attemptRecovery(page, ctx, "add-to-cart", "Clicar no botão de comprar/adicionar ao carrinho", selFor(ctx, "buyButton"));
12781
- if (recovery) {
12782
- buyHit = recovery;
12783
- buyRecovered = true;
12784
- budget.remaining--;
13000
+ if (!buyHit) {
13001
+ const landingCheck = await detectLandingPage(page);
13002
+ if (landingCheck.isLanding) {
13003
+ steps.push(makeSkipStep(6, "add-to-cart", ctx, `PDP appears to be a landing page (${landingCheck.reasons.join("; ")}) — no buy form to test`));
13004
+ reportEnd(6, "add-to-cart", "skipped", 0, "landing page detected");
13005
+ return { pages, steps };
13006
+ }
13007
+ if (budget.remaining > 0) {
13008
+ const recovery = await attemptRecovery(page, ctx, "add-to-cart", "Clicar no botão de comprar/adicionar ao carrinho", selFor(ctx, "buyButton"));
13009
+ if (recovery) {
13010
+ buyHit = recovery;
13011
+ buyRecovered = true;
13012
+ budget.remaining--;
13013
+ }
12785
13014
  }
12786
13015
  }
12787
13016
  if (!buyHit) {
@@ -13232,6 +13461,56 @@ async function fillCep(page, selector, cep) {
13232
13461
  return false;
13233
13462
  }
13234
13463
  }
13464
+ async function detectLandingPage(page) {
13465
+ const reasons = [];
13466
+ let pdpSignalCount = 0;
13467
+ try {
13468
+ const hasSchema = await page.locator(`script[type='application/ld+json']:has-text('"@type":"Product"')`).first().count().catch(() => 0);
13469
+ if (hasSchema > 0)
13470
+ pdpSignalCount++;
13471
+ else
13472
+ reasons.push("no schema:Product JSON-LD");
13473
+ } catch {
13474
+ reasons.push("no schema:Product JSON-LD");
13475
+ }
13476
+ try {
13477
+ const hasItemtype = await page.locator("[itemtype*='Product']").first().count();
13478
+ if (hasItemtype > 0)
13479
+ pdpSignalCount++;
13480
+ } catch {}
13481
+ try {
13482
+ const hasForm = await page.locator("form:has(button)").first().count();
13483
+ if (hasForm > 0)
13484
+ pdpSignalCount++;
13485
+ else
13486
+ reasons.push("no <form> with button");
13487
+ } catch {
13488
+ reasons.push("no <form> with button");
13489
+ }
13490
+ try {
13491
+ const bodyText = await page.locator("body").innerText({ timeout: 500 });
13492
+ if (/R\$\s*\d+|\$\s*\d+\.\d{2}|€\s*\d+|\bUSD\s*\d+/i.test(bodyText)) {
13493
+ pdpSignalCount++;
13494
+ } else {
13495
+ reasons.push("no price text (R$ / $ / €)");
13496
+ }
13497
+ } catch {
13498
+ reasons.push("no price text (R$ / $ / €)");
13499
+ }
13500
+ try {
13501
+ const hasVariantInput = await page.locator("select, input[type='number'], input[type='radio']").first().count();
13502
+ if (hasVariantInput > 0)
13503
+ pdpSignalCount++;
13504
+ } catch {}
13505
+ return { isLanding: pdpSignalCount < 2, reasons };
13506
+ }
13507
+ async function clickAndMaybeWait(page, locator, _label) {
13508
+ await Promise.allSettled([
13509
+ page.waitForNavigation({ timeout: 5000, waitUntil: "domcontentloaded" }),
13510
+ locator.click({ timeout: 2000 })
13511
+ ]);
13512
+ await page.waitForTimeout(600);
13513
+ }
13235
13514
  async function selectVariant(page, ctx) {
13236
13515
  const actions = [];
13237
13516
  let primarySelectorKey;
@@ -13275,10 +13554,7 @@ async function selectVariant(page, ctx) {
13275
13554
  const sizeHit = await firstVisibleLocator(page, selFor(ctx, "sizeSwatch"));
13276
13555
  if (sizeHit && !await sizeHit.locator.isDisabled().catch(() => false)) {
13277
13556
  const sizeText = (await sizeHit.locator.innerText().catch(() => "")).slice(0, 20).trim();
13278
- await sizeHit.locator.click({ timeout: 2000 }).catch(() => {
13279
- return;
13280
- });
13281
- await page.waitForTimeout(400);
13557
+ await clickAndMaybeWait(page, sizeHit.locator, "sizeSwatch");
13282
13558
  actions.push(`Selecionou tamanho${sizeText ? ` '${sizeText}'` : ""} (\`${sizeHit.selector}\`)`);
13283
13559
  trackPrimary("sizeSwatch", sizeHit.selector);
13284
13560
  }
@@ -13287,10 +13563,7 @@ async function selectVariant(page, ctx) {
13287
13563
  if (colorHit && !await colorHit.locator.isDisabled().catch(() => false)) {
13288
13564
  const colorText = (await colorHit.locator.innerText().catch(() => "")).slice(0, 20).trim();
13289
13565
  const colorLabel = colorText || await colorHit.locator.getAttribute("aria-label").catch(() => null) || "";
13290
- await colorHit.locator.click({ timeout: 2000 }).catch(() => {
13291
- return;
13292
- });
13293
- await page.waitForTimeout(400);
13566
+ await clickAndMaybeWait(page, colorHit.locator, "colorSwatch");
13294
13567
  actions.push(`Selecionou cor${colorLabel ? ` '${colorLabel}'` : ""} (\`${colorHit.selector}\`)`);
13295
13568
  trackPrimary("colorSwatch", colorHit.selector);
13296
13569
  }
@@ -13441,6 +13714,7 @@ async function validateAddToCart(page, ctx, cartCountBefore, beforeUrl) {
13441
13714
  };
13442
13715
  }
13443
13716
  const drawerSelectors = [
13717
+ ...selFor(ctx, "cartOpenedIndicator"),
13444
13718
  "[role='dialog']",
13445
13719
  "[data-minicart][aria-expanded='true']",
13446
13720
  "[data-cart-drawer].open",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/parity",
3
- "version": "0.11.4",
3
+ "version": "0.11.6",
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",