@decocms/parity 0.11.3 → 0.11.5

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 +45 -0
  2. package/dist/cli.js +199 -25
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,51 @@ 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.5](https://github.com/decocms/parity/compare/v0.11.4...v0.11.5) (2026-06-17)
9
+
10
+ ### Fixed
11
+
12
+ * **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).
13
+ * **`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.
14
+
15
+ ### Added
16
+
17
+ * **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.
18
+
19
+ ### Verification (live against bagaggio)
20
+
21
+ | Version | Steps reached on cand |
22
+ | --- | --- |
23
+ | 0.11.2 and earlier | 3/9 (stops at enter-pdp) |
24
+ | 0.11.3 | 4/9 (stops at select-variant) |
25
+ | 0.11.4 | 6/9 (stops at add-to-cart) |
26
+ | **0.11.5** | **9/9** (reaches go-checkout) |
27
+
28
+ ## [0.11.4](https://github.com/decocms/parity/compare/v0.11.3...v0.11.4) (2026-06-17)
29
+
30
+ ### Added
31
+
32
+ * **`cartOpenedIndicator` selector key.** Used by the upcoming post-add-to-cart validator — list of selectors that, when matched and visible after clicking buy, confirm the cart actually opened (notification toast, drawer, etc). Default candidates include `[aria-label='Fechar notificação']` / `[aria-label='Fechar carrinho']` patterns from Deco TanStack plus generic role/`[role='dialog']` fallbacks.
33
+
34
+ ### Fixed
35
+
36
+ * **Deco TanStack selectors covered before the LLM is even called.** Defaults extended for the bagaggio-class storefront — patterns observed live against `bagaggio-tanstack.deco-cx.workers.dev`:
37
+ - **buyButton:** lowercase `comprar` variants + `button[type='button']:has-text('comprar')` (the CSS renders uppercase via `text-transform`; the markup is lowercase).
38
+ - **minicartTrigger:** `[aria-label='Sacola']` + lowercase aria substring.
39
+ - **cepInputPdp:** `input[name='postalCode']`, `#postalCodeInput`, `input[inputMode='numeric'][maxLength='8']`.
40
+ - **sizeSwatch:** `[aria-label*='Tamanho '][aria-label*='Disponível']` — the " - Disponível" suffix means in-stock; sold-out variants don't carry it.
41
+ * **HTML compaction passed to the LLM is now actually useful.** Both `compactHtmlForSelectors` and `compactHtmlForRecovery` now:
42
+ - Strip the Tailwind utility-class soup (`class="w-full h-12 flex items-center bg-primary ..."` becomes `class=""`) so the LLM sees semantic anchors (`data-*`, `aria-*`, `role`, semantic class names) instead of drowning in token noise.
43
+ - Strip URL-encoded JSON in `data-event` / `data-track` / `data-analytics` attrs (Deco sites carry multi-kb analytics blobs there).
44
+ - Drop inline `style=""`.
45
+ - Add `data-product-list` and `[aria-label]` to the kept-element whitelist so the Deco TanStack patterns survive the compaction.
46
+ * **LLM prompts know about Deco TanStack now.** `discover-selectors` and `recover-step` system prompts both document the bagaggio-class patterns explicitly (product card via `aria-label='view product'`, lowercase CTA text, `[aria-label='Sacola']` minicart, `[aria-label='Tamanho X - Disponível']` size swatches, `name='postalCode'` CEP input) so when defaults miss the LLM has structured guidance instead of having to re-discover the pattern each time.
47
+
48
+ ### Verification (live against bagaggio)
49
+
50
+ Before this PR: journey stopped at step 3 (`enter-pdp` — fixed in 0.11.3) and again at step 4 (`select-variant`).
51
+ After this PR: journey completes steps 1-5 (`visit-home`, `navigate-plp`, `enter-pdp`, `select-variant`, `shipping-calc-pdp`). Step 6 (`add-to-cart`) now surfaces a real bug — "Selecione um tamanho" is still visible after the variant click, meaning the variant selector matched a tab/expand element instead of the actual radio. Follow-up tracked separately.
52
+
8
53
  ## [0.11.3](https://github.com/decocms/parity/compare/v0.11.2...v0.11.3) (2026-06-17)
9
54
 
10
55
  ### Fixed
package/dist/cli.js CHANGED
@@ -4441,6 +4441,7 @@ var ParityRc = z.object({
4441
4441
  quantityIncrement: z.string().optional(),
4442
4442
  quantityInput: z.string().optional(),
4443
4443
  minicartCount: z.string().optional(),
4444
+ cartOpenedIndicator: z.string().optional(),
4444
4445
  searchTrigger: z.string().optional(),
4445
4446
  searchInput: z.string().optional(),
4446
4447
  searchSuggestions: z.string().optional(),
@@ -11247,7 +11248,26 @@ function compactHtmlForRecovery(html, maxChars = 12000) {
11247
11248
  try {
11248
11249
  const $ = cheerio6.load(html);
11249
11250
  $("script, style, noscript, svg, picture source, link, meta").remove();
11250
- const allowed = $("header, nav, main, footer, dialog, [role='dialog'], form, button, a, input, [data-buy-button], [data-checkout], [data-minicart], [role='button']").map((_, el) => $.html(el)).get().join(`
11251
+ $("*").each((_, el) => {
11252
+ const attrs = el.attribs ?? {};
11253
+ for (const name of Object.keys(attrs)) {
11254
+ if (name === "data-event" || name === "data-track" || name === "data-analytics") {
11255
+ const value = attrs[name] ?? "";
11256
+ if (value.length > 100)
11257
+ attrs[name] = "[…]";
11258
+ }
11259
+ if (name === "style")
11260
+ delete attrs[name];
11261
+ }
11262
+ if (attrs.class) {
11263
+ const tokens = attrs.class.split(/\s+/).filter(Boolean);
11264
+ const kept = tokens.filter((t) => isSemanticClassToken(t));
11265
+ attrs.class = kept.join(" ");
11266
+ if (!attrs.class)
11267
+ delete attrs.class;
11268
+ }
11269
+ });
11270
+ const allowed = $("header, nav, main, footer, dialog, [role='dialog'], form, button, a, input, [data-buy-button], [data-checkout], [data-minicart], [data-product-list], [role='button'], [aria-label]").map((_, el) => $.html(el)).get().join(`
11251
11271
  `);
11252
11272
  const truncated = allowed.length > maxChars ? `${allowed.slice(0, maxChars)}
11253
11273
  <!-- TRUNCATED -->` : allowed;
@@ -11256,6 +11276,23 @@ function compactHtmlForRecovery(html, maxChars = 12000) {
11256
11276
  return html.slice(0, maxChars);
11257
11277
  }
11258
11278
  }
11279
+ function isSemanticClassToken(token) {
11280
+ if (token.length === 0 || token.length > 40)
11281
+ return false;
11282
+ if (token.includes(":") || token.includes("[") || token.includes("/"))
11283
+ return false;
11284
+ if (/^[a-z]{1,4}(-[a-z]{1,3})?-\d/.test(token))
11285
+ return false;
11286
+ if (/^[a-z]{1,2}\d+$/.test(token))
11287
+ return false;
11288
+ if (/^(w|h|p|m|px|py|pt|pb|pl|pr|mt|mb|ml|mr|gap|flex|grid|text|bg|border|rounded|shadow|opacity|cursor|min|max)-/i.test(token) && !/^(text|bg|border)-(primary|secondary|accent|brand|warning|error|success|muted|base|surface)$/i.test(token)) {
11289
+ return false;
11290
+ }
11291
+ if (/^(flex|grid|block|inline|hidden|relative|absolute|fixed|static|sticky|visible|invisible|truncate|uppercase|lowercase|capitalize|italic|underline|overline|line-through|no-underline|antialiased|subpixel-antialiased|whitespace|break-words|break-all|object-cover|object-contain|object-fill|object-none|object-scale-down|select-none|select-text|select-all|pointer-events-none|pointer-events-auto|appearance-none|resize-none|leading-none|font-bold|font-medium|font-semibold|font-light|tracking-wide|tracking-tight)$/.test(token)) {
11292
+ return false;
11293
+ }
11294
+ return true;
11295
+ }
11259
11296
  var RECOVERY_SYSTEM_PROMPT = `
11260
11297
  Você ajuda a recuperar passos de teste E2E (Playwright) que falharam contra sites de e-commerce.
11261
11298
 
@@ -11277,22 +11314,36 @@ REGRAS CRÍTICAS:
11277
11314
  3. **Se o step é 'shipping-calc-cart' ou 'shipping-calc-pdp':**
11278
11315
  - O alvo é um \`<input>\` (não button/link) visível com label/placeholder mencionando "CEP", "Frete", "Calcular", "Entrega", "Zip".
11279
11316
  - Prefira inputs cujo container/pai está visível (não escondido em accordion fechado).
11317
+ - **Pattern Deco TanStack**: \`<input id="postalCodeInput" name="postalCode" inputMode="numeric" maxLength="8" pattern="\\\\d{8}">\`. Selectors válidos: \`input[name='postalCode']\`, \`#postalCodeInput\`, \`input[inputMode='numeric'][maxLength='8']\`.
11318
+
11319
+ 4. **Se o step é 'enter-pdp' ou 'navigate-plp' e o alvo é um product card:**
11320
+ - **Pattern Deco TanStack**: \`<div data-product-list><div class="card"><a aria-label="view product" href="<product-name>/p">...</a></div></div>\`.
11321
+ - Selectors válidos: \`[data-product-list] a[aria-label='view product']\`, \`a[aria-label='view product']\`, \`[data-product-list] a[href$='/p']\`.
11322
+
11323
+ 5. **Se o step é 'select-variant' (tamanho/cor obrigatório no PDP):**
11324
+ - **Pattern Deco TanStack**: \`<button aria-label="Tamanho P - Disponível">\` ou \`<button aria-label="Tamanho M - Disponível">\`. Sufixo " - Disponível" = em estoque; "Esgotado" / sem sufixo = indisponível.
11325
+ - Selectors válidos: \`[aria-label*='Tamanho '][aria-label*='Disponível']\`, \`button[aria-label*='Tamanho ']:not([disabled])\`.
11326
+ - Para cor: \`[aria-label*='Cor '][aria-label*='Disponível']\`.
11327
+
11328
+ 6. **Se o step é 'add-to-cart' / 'comprar':**
11329
+ - **Pattern Deco TanStack**: o markup é \`<button type="button">comprar</button>\` em lowercase (CSS faz uppercase via \`text-transform\`). Use \`button:has-text('comprar')\` ou \`button[type='button']:has-text('comprar')\`.
11330
+ - **NUNCA confunda com "comprar junto"** (botão de cross-sell adjacente) — qualifique pelo texto exato ou pelo \`type='submit'\`.
11280
11331
 
11281
- 4. **Seletores preferidos** (nesta ordem):
11332
+ 7. **Seletores preferidos** (nesta ordem):
11282
11333
  - Text-based Playwright: \`button:has-text('Finalizar')\`, \`a:has-text('Continuar para pagamento')\`
11283
- - data-attributes específicos: \`[data-checkout-button]\`, \`[data-cart-finalize]\`, \`[data-fs-cart-button]\`
11284
- - ARIA: \`[aria-label*='finalizar' i]\`, \`[role='button'][aria-label*='checkout' i]\`
11334
+ - data-attributes específicos: \`[data-checkout-button]\`, \`[data-cart-finalize]\`, \`[data-fs-cart-button]\`, \`[data-product-list]\`
11335
+ - ARIA: \`[aria-label*='finalizar' i]\`, \`[role='button'][aria-label*='checkout' i]\`, \`[aria-label='Sacola']\` (Deco TanStack minicart)
11285
11336
  - Classes específicas de plataforma: \`.vtex-minicart-2-x-checkoutButton\`, \`.fs-cart__checkout\`
11286
11337
  - Por último, escopo hierárquico curto: \`[role='dialog'] button.cta\`, \`.minicart-drawer button[type='submit']\`
11287
11338
 
11288
- 5. **EVITE:**
11339
+ 8. **EVITE:**
11289
11340
  - IDs hash-gerados (\`#r-abc123\`)
11290
- - Tailwind utility classes encadeadas (\`.flex.items-center.bg-blue\`)
11341
+ - Tailwind utility classes encadeadas (\`.flex.items-center.bg-blue\`) — o HTML compactado já tirou essas, então se você só vê tokens utility a class foi removida e essa não é uma âncora confiável
11291
11342
  - Seletores genéricos sem qualificador (\`a[href*=...]\`, \`button[type=submit]\` sozinho)
11292
11343
  - Seletores que casam o ELEMENTO ERRADO em outra parte do DOM (ex: link de "checkout your reviews" no footer)
11293
11344
  - Seletores muito profundos (>4 níveis hierárquicos)
11294
11345
 
11295
- 6. **Quando incerto, retorne selector="" (string vazia).** É preferível que o flow declare a falha honestamente a chutar um seletor errado que apenas clica em coisa aleatória.
11346
+ 9. **Quando incerto, retorne selector="" (string vazia).** É preferível que o flow declare a falha honestamente a chutar um seletor errado que apenas clica em coisa aleatória. Em especial: se a página parece ser uma LANDING PAGE (sem buy form, sem schema:Product, sem preço) e o step pede um buy/variant/cep selector, retorne string vazia — não force uma sugestão.
11296
11347
 
11297
11348
  Responda SEMPRE via tool_use suggest_recovery.
11298
11349
  `.trim();
@@ -11440,7 +11491,13 @@ REGRAS:
11440
11491
  - Se o HTML tem classes \`vtex-\` → VTEX. Use convenções VTEX (\`.vtex-cart-item, .vtex-minicart\`).
11441
11492
  - Se tem \`fs-\` ou \`data-fs-*\` → FastStore/VTEX IO.
11442
11493
  - Se tem \`shopify-\` ou usa Liquid markup → Shopify.
11443
- - Se tem \`data-deco\`, \`data-section\`, \`data-product-card\` → Deco storefront (variantes custom).
11494
+ - Se tem \`data-deco\`, \`data-section\`, \`data-product-card\` → Deco storefront classic (Fresh/Preact).
11495
+ - Se tem \`data-product-list\`, \`<a aria-label="view product">\`, e Tailwind utility classes
11496
+ (\`card w-full card-compact ...\`) → **Deco TanStack** (React port). Patterns conhecidos:
11497
+ * product card: \`[data-product-list] a[aria-label='view product']\` ou \`a[href$='/p']\`
11498
+ * minicart trigger: \`[aria-label='Sacola']\` (note: nome em português)
11499
+ * post-add-to-cart drawer/toast: \`[aria-label='Fechar carrinho']\` ou \`[aria-label='Fechar notificação']\`
11500
+ * CTAs ficam lowercase no markup (\`comprar\`, não \`Comprar\`) com text-transform via CSS
11444
11501
  - Caso desconhecido, use heurísticas genéricas.
11445
11502
 
11446
11503
  4. Para "cep_input_pdp" e "cep_input_cart": retorne string vazia se não existir esse recurso na plataforma.
@@ -11480,6 +11537,25 @@ function compactHtmlForSelectors(html, maxChars = 30000) {
11480
11537
  const $ = cheerio7.load(html);
11481
11538
  $("script, style, noscript, svg, picture source, link[rel='stylesheet']").remove();
11482
11539
  $("[type='application/ld+json']").remove();
11540
+ $("*").each((_, el) => {
11541
+ const attrs = el.attribs ?? {};
11542
+ for (const name of Object.keys(attrs)) {
11543
+ if (name === "data-event" || name === "data-track" || name === "data-analytics") {
11544
+ const value = attrs[name] ?? "";
11545
+ if (value.length > 100)
11546
+ attrs[name] = "[…]";
11547
+ }
11548
+ if (name === "style")
11549
+ delete attrs[name];
11550
+ }
11551
+ if (attrs.class) {
11552
+ const tokens = attrs.class.split(/\s+/).filter(Boolean);
11553
+ const kept = tokens.filter((t) => isSemanticClass(t));
11554
+ attrs.class = kept.join(" ");
11555
+ if (!attrs.class)
11556
+ delete attrs.class;
11557
+ }
11558
+ });
11483
11559
  const sections = [];
11484
11560
  const head = $("head").clone();
11485
11561
  head.find("title, meta[name='generator'], meta[name='vtex'], meta[name='platform'], link[rel='canonical']").each((_, el) => {
@@ -11488,7 +11564,7 @@ function compactHtmlForSelectors(html, maxChars = 30000) {
11488
11564
  $("header, nav, [role='banner']").each((_, el) => {
11489
11565
  sections.push($.html(el));
11490
11566
  });
11491
- const shelf = $("[data-product-card], [data-deco='view-product'], article a[href*='/p/'], article a[href*='/products/']").closest("section, ul, div").first();
11567
+ const shelf = $("[data-product-card], [data-deco='view-product'], [data-product-list], article a[href*='/p/'], article a[href*='/products/'], a[aria-label='view product']").closest("section, ul, div").first();
11492
11568
  if (shelf.length > 0)
11493
11569
  sections.push($.html(shelf));
11494
11570
  $("form").each((_, el) => {
@@ -11508,6 +11584,23 @@ function compactHtmlForSelectors(html, maxChars = 30000) {
11508
11584
  return html.slice(0, maxChars);
11509
11585
  }
11510
11586
  }
11587
+ function isSemanticClass(token) {
11588
+ if (token.length === 0 || token.length > 40)
11589
+ return false;
11590
+ if (token.includes(":") || token.includes("[") || token.includes("/"))
11591
+ return false;
11592
+ if (/^[a-z]{1,4}(-[a-z]{1,3})?-\d/.test(token))
11593
+ return false;
11594
+ if (/^[a-z]{1,2}\d+$/.test(token))
11595
+ return false;
11596
+ if (/^(w|h|p|m|px|py|pt|pb|pl|pr|mt|mb|ml|mr|gap|flex|grid|text|bg|border|rounded|shadow|opacity|cursor|min|max)-/i.test(token) && !/^(text|bg|border)-(primary|secondary|accent|brand|warning|error|success|muted|base|surface)$/i.test(token)) {
11597
+ return false;
11598
+ }
11599
+ if (/^(flex|grid|block|inline|hidden|relative|absolute|fixed|static|sticky|visible|invisible|truncate|uppercase|lowercase|capitalize|italic|underline|overline|line-through|no-underline|antialiased|subpixel-antialiased|whitespace|break-words|break-all|object-cover|object-contain|object-fill|object-none|object-scale-down|select-none|select-text|select-all|pointer-events-none|pointer-events-auto|appearance-none|resize-none|leading-none|font-bold|font-medium|font-semibold|font-light|tracking-wide|tracking-tight)$/.test(token)) {
11600
+ return false;
11601
+ }
11602
+ return true;
11603
+ }
11511
11604
  async function discoverSelectorsFromUrl(url, html, opts = {}) {
11512
11605
  const host = safeHost(url);
11513
11606
  const cacheDir = opts.cacheDir ?? CACHE_DIR;
@@ -11704,6 +11797,7 @@ var SelectorKey = z2.enum([
11704
11797
  "quantityIncrement",
11705
11798
  "quantityInput",
11706
11799
  "minicartCount",
11800
+ "cartOpenedIndicator",
11707
11801
  "searchTrigger",
11708
11802
  "searchInput",
11709
11803
  "searchSuggestions",
@@ -11886,17 +11980,22 @@ var DEFAULT_SELECTORS = {
11886
11980
  "main a[href*='/p?']"
11887
11981
  ],
11888
11982
  buyButton: [
11983
+ "[data-buy-button]",
11984
+ "[data-testid='add-to-cart']",
11985
+ "button[type='button']:has-text('comprar')",
11986
+ "button[type='submit']:has-text('comprar')",
11889
11987
  "button:has-text('Comprar')",
11988
+ "button:has-text('comprar')",
11890
11989
  "button:has-text('Adicionar')",
11891
11990
  "button:has-text('Add to cart')",
11892
11991
  "button:has-text('Add to bag')",
11893
- "[data-buy-button]",
11894
- "[data-testid='add-to-cart']",
11895
11992
  "button[type='submit']:has-text('Comprar')"
11896
11993
  ],
11897
11994
  minicartTrigger: [
11898
11995
  "[data-minicart-trigger]",
11899
11996
  "[data-testid='minicart-trigger']",
11997
+ "[aria-label='Sacola']",
11998
+ "[aria-label*='sacola' i]",
11900
11999
  "[aria-label*='carrinho' i]",
11901
12000
  "[aria-label*='cart' i]",
11902
12001
  "header button:has([data-cart-icon])",
@@ -11906,6 +12005,10 @@ var DEFAULT_SELECTORS = {
11906
12005
  "input[name='shipping-zipcode']",
11907
12006
  "input[name='zipcode']",
11908
12007
  "input[name='cep']",
12008
+ "input[name='postalCode']",
12009
+ "#postalCodeInput",
12010
+ "input[inputMode='numeric'][maxLength='8']",
12011
+ "input[pattern='\\\\d{8}']",
11909
12012
  "input[placeholder*='CEP' i]",
11910
12013
  "input[placeholder*='Postal' i]",
11911
12014
  "[data-shipping-input] input"
@@ -11936,6 +12039,9 @@ var DEFAULT_SELECTORS = {
11936
12039
  sizeSwatch: [
11937
12040
  "[data-size]:not([disabled]):not([aria-disabled='true']):not(.unavailable):not(.sold-out)",
11938
12041
  "[data-variant-size]:not([disabled]):not([aria-disabled='true']):not(.unavailable)",
12042
+ "[aria-label^='Tamanho ']:has-text('Disponível')",
12043
+ "[aria-label*='Tamanho '][aria-label*='Disponível']",
12044
+ "[aria-label*='Tamanho ']:not([disabled]):not([aria-disabled='true'])",
11939
12045
  "button[aria-label*='tamanho' i]:not([aria-disabled='true']):not([disabled])",
11940
12046
  "button[aria-label*='size' i]:not([aria-disabled='true']):not([disabled])",
11941
12047
  "[data-testid='size-selector'] button:not([disabled]):not(.unavailable)",
@@ -12040,6 +12146,21 @@ var DEFAULT_SELECTORS = {
12040
12146
  "[class*='searchSuggestions']:visible",
12041
12147
  "[class*='suggestions' i] [class*='item' i]"
12042
12148
  ],
12149
+ cartOpenedIndicator: [
12150
+ "[aria-label='Fechar notificação']:visible",
12151
+ "[aria-label*='notificação' i]:visible",
12152
+ "[role='status']:visible",
12153
+ "[role='alert']:visible",
12154
+ "[aria-label='Fechar carrinho']:visible",
12155
+ "[aria-label*='fechar carrinho' i]:visible",
12156
+ "[role='dialog']:has([class*='cart' i]):visible",
12157
+ "[role='dialog']:has([class*='minicart' i]):visible",
12158
+ "[data-cart-drawer][aria-expanded='true']",
12159
+ "[data-minicart][aria-expanded='true']",
12160
+ "[data-cart-drawer]:not([hidden]):visible",
12161
+ "[role='dialog']:has-text('adicionado')",
12162
+ "[role='dialog']:has-text('added')"
12163
+ ],
12043
12164
  cartItemRow: [
12044
12165
  "[data-cart-item]",
12045
12166
  "[data-testid='cart-item']",
@@ -12655,12 +12776,20 @@ async function flowPurchaseJourney(ctx) {
12655
12776
  reportStart(6, "add-to-cart");
12656
12777
  let buyHit = await firstVisibleLocator(page, selFor(ctx, "buyButton"));
12657
12778
  let buyRecovered = false;
12658
- if (!buyHit && budget.remaining > 0) {
12659
- const recovery = await attemptRecovery(page, ctx, "add-to-cart", "Clicar no botão de comprar/adicionar ao carrinho", selFor(ctx, "buyButton"));
12660
- if (recovery) {
12661
- buyHit = recovery;
12662
- buyRecovered = true;
12663
- budget.remaining--;
12779
+ if (!buyHit) {
12780
+ const landingCheck = await detectLandingPage(page);
12781
+ if (landingCheck.isLanding) {
12782
+ steps.push(makeSkipStep(6, "add-to-cart", ctx, `PDP appears to be a landing page (${landingCheck.reasons.join("; ")}) — no buy form to test`));
12783
+ reportEnd(6, "add-to-cart", "skipped", 0, "landing page detected");
12784
+ return { pages, steps };
12785
+ }
12786
+ if (budget.remaining > 0) {
12787
+ const recovery = await attemptRecovery(page, ctx, "add-to-cart", "Clicar no botão de comprar/adicionar ao carrinho", selFor(ctx, "buyButton"));
12788
+ if (recovery) {
12789
+ buyHit = recovery;
12790
+ buyRecovered = true;
12791
+ budget.remaining--;
12792
+ }
12664
12793
  }
12665
12794
  }
12666
12795
  if (!buyHit) {
@@ -13111,6 +13240,56 @@ async function fillCep(page, selector, cep) {
13111
13240
  return false;
13112
13241
  }
13113
13242
  }
13243
+ async function detectLandingPage(page) {
13244
+ const reasons = [];
13245
+ let pdpSignalCount = 0;
13246
+ try {
13247
+ const hasSchema = await page.locator(`script[type='application/ld+json']:has-text('"@type":"Product"')`).first().count().catch(() => 0);
13248
+ if (hasSchema > 0)
13249
+ pdpSignalCount++;
13250
+ else
13251
+ reasons.push("no schema:Product JSON-LD");
13252
+ } catch {
13253
+ reasons.push("no schema:Product JSON-LD");
13254
+ }
13255
+ try {
13256
+ const hasItemtype = await page.locator("[itemtype*='Product']").first().count();
13257
+ if (hasItemtype > 0)
13258
+ pdpSignalCount++;
13259
+ } catch {}
13260
+ try {
13261
+ const hasForm = await page.locator("form:has(button)").first().count();
13262
+ if (hasForm > 0)
13263
+ pdpSignalCount++;
13264
+ else
13265
+ reasons.push("no <form> with button");
13266
+ } catch {
13267
+ reasons.push("no <form> with button");
13268
+ }
13269
+ try {
13270
+ const bodyText = await page.locator("body").innerText({ timeout: 500 });
13271
+ if (/R\$\s*\d+|\$\s*\d+\.\d{2}|€\s*\d+|\bUSD\s*\d+/i.test(bodyText)) {
13272
+ pdpSignalCount++;
13273
+ } else {
13274
+ reasons.push("no price text (R$ / $ / €)");
13275
+ }
13276
+ } catch {
13277
+ reasons.push("no price text (R$ / $ / €)");
13278
+ }
13279
+ try {
13280
+ const hasVariantInput = await page.locator("select, input[type='number'], input[type='radio']").first().count();
13281
+ if (hasVariantInput > 0)
13282
+ pdpSignalCount++;
13283
+ } catch {}
13284
+ return { isLanding: pdpSignalCount < 2, reasons };
13285
+ }
13286
+ async function clickAndMaybeWait(page, locator, _label) {
13287
+ await Promise.allSettled([
13288
+ page.waitForNavigation({ timeout: 5000, waitUntil: "domcontentloaded" }),
13289
+ locator.click({ timeout: 2000 })
13290
+ ]);
13291
+ await page.waitForTimeout(600);
13292
+ }
13114
13293
  async function selectVariant(page, ctx) {
13115
13294
  const actions = [];
13116
13295
  let primarySelectorKey;
@@ -13154,10 +13333,7 @@ async function selectVariant(page, ctx) {
13154
13333
  const sizeHit = await firstVisibleLocator(page, selFor(ctx, "sizeSwatch"));
13155
13334
  if (sizeHit && !await sizeHit.locator.isDisabled().catch(() => false)) {
13156
13335
  const sizeText = (await sizeHit.locator.innerText().catch(() => "")).slice(0, 20).trim();
13157
- await sizeHit.locator.click({ timeout: 2000 }).catch(() => {
13158
- return;
13159
- });
13160
- await page.waitForTimeout(400);
13336
+ await clickAndMaybeWait(page, sizeHit.locator, "sizeSwatch");
13161
13337
  actions.push(`Selecionou tamanho${sizeText ? ` '${sizeText}'` : ""} (\`${sizeHit.selector}\`)`);
13162
13338
  trackPrimary("sizeSwatch", sizeHit.selector);
13163
13339
  }
@@ -13166,10 +13342,7 @@ async function selectVariant(page, ctx) {
13166
13342
  if (colorHit && !await colorHit.locator.isDisabled().catch(() => false)) {
13167
13343
  const colorText = (await colorHit.locator.innerText().catch(() => "")).slice(0, 20).trim();
13168
13344
  const colorLabel = colorText || await colorHit.locator.getAttribute("aria-label").catch(() => null) || "";
13169
- await colorHit.locator.click({ timeout: 2000 }).catch(() => {
13170
- return;
13171
- });
13172
- await page.waitForTimeout(400);
13345
+ await clickAndMaybeWait(page, colorHit.locator, "colorSwatch");
13173
13346
  actions.push(`Selecionou cor${colorLabel ? ` '${colorLabel}'` : ""} (\`${colorHit.selector}\`)`);
13174
13347
  trackPrimary("colorSwatch", colorHit.selector);
13175
13348
  }
@@ -13320,6 +13493,7 @@ async function validateAddToCart(page, ctx, cartCountBefore, beforeUrl) {
13320
13493
  };
13321
13494
  }
13322
13495
  const drawerSelectors = [
13496
+ ...selFor(ctx, "cartOpenedIndicator"),
13323
13497
  "[role='dialog']",
13324
13498
  "[data-minicart][aria-expanded='true']",
13325
13499
  "[data-cart-drawer].open",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/parity",
3
- "version": "0.11.3",
3
+ "version": "0.11.5",
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",