@decocms/parity 0.3.0 → 0.4.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 +27 -0
  2. package/dist/cli.js +728 -34
  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.4.0](https://github.com/decocms/parity/compare/v0.3.0...v0.4.0) (2026-05-27)
9
+
10
+
11
+ ### Added
12
+
13
+ * **journey:** extract product title on PDP and validate the same product appears in cart drawer (step 6) and on checkout page (step 8). 30+ selectors cover VTEX legacy, checkout6, FastStore, Wake. ([PR #11](https://github.com/decocms/parity/pull/11))
14
+ * **journey:** viewport-aware minicart open strategy — desktop tries hover first (popup-style minicarts on VTEX prod), mobile tries `tap()` first (real touch event bypasses overlay handlers that swallow synthetic clicks). Adds `force: true` click + goto-href fallback when interactive strategies fail.
15
+ * **journey:** `dismissOverlays` actively closes cookie banners, add-to-cart toasts and `[role=alertdialog]` before interacting with the minicart trigger.
16
+ * **journey:** `waitForCartHydration` waits for the orderForm XHR + first cart-item selector before validation runs after `page.goto('/checkout/#/cart')`.
17
+ * **journey:** step 8 advance-checkout mode — when URL is already on a checkout subpage, prepends 15 next-step selectors (`#cart-to-orderform`, `a.orange-btn`, `:has-text('Continuar para pagamento')`, etc.) and waits for URL change instead of a `/checkout` match.
18
+ * **journey:** empty-cart banner detection populates `step.cartValidation.reason` to distinguish "cart genuinely empty (session not persisting)" from "selectors don't match markup".
19
+ * **journey:** `DEBUG_PARITY=1` env var enables structured per-step + per-substep dlog output to stderr.
20
+ * **schema:** `StepCapture.cartValidation` (expectedTitle, found, method, observedTitles, reason) and `cartOpenMethod` (click | click-navigate | hover | already-open | failed) for report traceability.
21
+
22
+
23
+ ### Fixed
24
+
25
+ * **journey:** `collectCandidateLinks` 15s budget + per-op `withCap` race — prevents indefinite hang when the page V8 main thread is wedged by memory leaks (CDP messages queue past `locator.count()` declared timeout).
26
+ * **journey:** page-close cap of 5s in the flow timeout cleanup — closes never block the next flow indefinitely on a wedged page.
27
+ * **journey:** flow-timeout step renamed from `visit-home` to `flow-timeout` so the summary shows honestly which step the deadline aborted.
28
+ * **journey:** isReachedCheckout regex accepts 13 checkout-flow URL markers (VTEX `/checkout`, Shopify `/checkouts`, Wake `/pedido`, Magento `/onepage`, Nuvemshop `/finalizar`, custom `/secure`, `/pagamento`, etc.) — no longer too strict to VTEX-only patterns.
29
+ * **journey:** `validateCartContainsTitle` scope-qualifies the `[data-product-name]` selector to cart/drawer/checkout/minicart context — prevents false positives where the PDP `<h1>` matched the cart-context selector.
30
+ * **journey:** `validateCartContainsTitle` retries after 2s on empty observation — catches in-flight cart-items XHR finishing slightly late.
31
+ * **journey:** `waitForCartHydration` uses Promise.race (not Promise.all) so the faster of orderForm-XHR / cart-item-selector signals wins, avoiding 8s stalls when one probe never matches.
32
+ * **llm:** `tryRepairJson` recovers from truncated/fenced tool-call arguments returned by some OpenRouter-backed models.
33
+ * **llm:** recovery prompt accepts `a[href*=checkout]` when qualified by text or scope (e.g. `:has-text('Finalizar')`, `[role=dialog] …`). Previous version was too strict and the LLM returned null even when the right element was findable.
34
+
8
35
  ## [0.3.0](https://github.com/decocms/parity/compare/v0.2.0...v0.3.0) (2026-05-26)
9
36
 
10
37
 
package/dist/cli.js CHANGED
@@ -99,7 +99,15 @@ var StepCapture = z.object({
99
99
  actionDescription: z.string().optional(),
100
100
  beforeUrl: z.string().optional(),
101
101
  screenshotBeforePath: z.string().optional(),
102
- tracePath: z.string().optional()
102
+ tracePath: z.string().optional(),
103
+ cartValidation: z.object({
104
+ expectedTitle: z.string(),
105
+ found: z.boolean(),
106
+ method: z.enum(["selector", "llm", "none"]).optional(),
107
+ observedTitles: z.array(z.string()).optional(),
108
+ reason: z.string().optional()
109
+ }).optional(),
110
+ cartOpenMethod: z.enum(["click", "click-navigate", "hover", "already-open", "failed"]).optional()
103
111
  });
104
112
  var FlowCapture = z.object({
105
113
  flow: FlowName,
@@ -931,6 +939,15 @@ async function stopTracing(ctx, path) {
931
939
  }
932
940
 
933
941
  // src/engine/collect.ts
942
+ var DEBUG_PARITY = process.env.DEBUG_PARITY === "1" || process.env.DEBUG_PARITY === "true";
943
+ var DEBUG_START = Date.now();
944
+ function dlog(side, viewport, msg) {
945
+ if (!DEBUG_PARITY)
946
+ return;
947
+ const elapsed = ((Date.now() - DEBUG_START) / 1000).toFixed(1);
948
+ process.stderr.write(`[+${elapsed}s ${viewport}/${side}] ${msg}
949
+ `);
950
+ }
934
951
  var VITALS_INIT_SCRIPT = `
935
952
  (function() {
936
953
  if (window.__parity_vitals_installed) return;
@@ -1141,10 +1158,12 @@ async function capturePage(page, opts) {
1141
1158
  let html = "";
1142
1159
  const inner = async () => {
1143
1160
  try {
1161
+ dlog(opts.side, opts.viewport, ` capturePage: goto(${opts.url}) start`);
1144
1162
  response = await page.goto(opts.url, {
1145
1163
  waitUntil: "domcontentloaded",
1146
1164
  timeout: Math.min(opts.timeoutMs ?? 30000, remaining())
1147
1165
  });
1166
+ dlog(opts.side, opts.viewport, ` capturePage: goto done status=${response?.status() ?? "?"} (remaining=${remaining()}ms)`);
1148
1167
  finalUrl = page.url();
1149
1168
  if (response) {
1150
1169
  const headers = response.headers();
@@ -1156,20 +1175,25 @@ async function capturePage(page, opts) {
1156
1175
  });
1157
1176
  await page.waitForTimeout(Math.min(opts.settleMs ?? 1200, remaining()));
1158
1177
  } else {
1178
+ dlog(opts.side, opts.viewport, ` capturePage: waitForLoadState('load') (cap=${Math.min(12000, remaining())}ms)`);
1159
1179
  await page.waitForLoadState("load", { timeout: Math.min(12000, remaining()) }).catch(() => {
1160
1180
  return;
1161
1181
  });
1182
+ dlog(opts.side, opts.viewport, ` capturePage: waitForLoadState('networkidle') (cap=${Math.min(6000, remaining())}ms)`);
1162
1183
  await page.waitForLoadState("networkidle", { timeout: Math.min(6000, remaining()) }).catch(() => {
1163
1184
  return;
1164
1185
  });
1186
+ dlog(opts.side, opts.viewport, ` capturePage: settle (cap=${Math.min(opts.settleMs ?? 2000, remaining())}ms)`);
1165
1187
  await page.waitForTimeout(Math.min(opts.settleMs ?? 2000, remaining()));
1166
1188
  if (opts.scrollToLoad !== false && remaining() > 3000) {
1189
+ dlog(opts.side, opts.viewport, ` capturePage: scrollFullPage start (remaining=${remaining()}ms)`);
1167
1190
  await Promise.race([
1168
1191
  scrollFullPage(page).catch(() => {
1169
1192
  return;
1170
1193
  }),
1171
1194
  new Promise((resolve) => setTimeout(resolve, Math.min(1e4, remaining())))
1172
1195
  ]);
1196
+ dlog(opts.side, opts.viewport, ` capturePage: scrollFullPage done (remaining=${remaining()}ms)`);
1173
1197
  await page.waitForTimeout(Math.min(600, remaining()));
1174
1198
  }
1175
1199
  }
@@ -1179,23 +1203,29 @@ async function capturePage(page, opts) {
1179
1203
  text: `[navigation-error] ${err.message}`
1180
1204
  });
1181
1205
  }
1206
+ dlog(opts.side, opts.viewport, ` capturePage: vitals evaluate (cap=${Math.min(5000, remaining())}ms)`);
1182
1207
  vitals = await Promise.race([
1183
1208
  page.evaluate(() => window.__parity_vitals).catch(() => null),
1184
1209
  new Promise((resolve) => setTimeout(() => resolve(null), Math.min(5000, remaining())))
1185
1210
  ]) ?? null;
1186
1211
  if (!opts.skipScreenshot) {
1212
+ dlog(opts.side, opts.viewport, ` capturePage: screenshot start (cap=${Math.min(15000, remaining())}ms)`);
1187
1213
  await Promise.race([
1188
1214
  page.screenshot({ path: opts.screenshotPath, fullPage: true, animations: "disabled" }).catch(() => {
1189
1215
  return;
1190
1216
  }),
1191
1217
  new Promise((resolve) => setTimeout(resolve, Math.min(15000, remaining())))
1192
1218
  ]);
1219
+ dlog(opts.side, opts.viewport, " capturePage: screenshot done");
1193
1220
  }
1221
+ dlog(opts.side, opts.viewport, ` capturePage: page.content() (cap=${Math.min(5000, remaining())}ms)`);
1194
1222
  html = await Promise.race([
1195
1223
  page.content().catch(() => ""),
1196
1224
  new Promise((resolve) => setTimeout(() => resolve(""), Math.min(5000, remaining())))
1197
1225
  ]);
1226
+ dlog(opts.side, opts.viewport, ` capturePage: flushCollectors (cap=${Math.min(3000, remaining())}ms)`);
1198
1227
  await flushCollectors(state, Math.min(3000, remaining()));
1228
+ dlog(opts.side, opts.viewport, ` capturePage: inner done total=${Date.now() - start}ms`);
1199
1229
  return buildPartial();
1200
1230
  };
1201
1231
  const SAFETY_MARGIN_MS = 1e4;
@@ -4491,10 +4521,17 @@ async function callOpenRouterTool(params) {
4491
4521
  const json = await response.json();
4492
4522
  const toolCall = json.choices?.[0]?.message?.tool_calls?.[0];
4493
4523
  if (toolCall?.function?.name === params.tool.name) {
4524
+ const raw = toolCall.function.arguments ?? "";
4494
4525
  try {
4495
- return JSON.parse(toolCall.function.arguments);
4496
- } catch (err) {
4497
- console.error(`[llm-openrouter] failed to parse tool arguments: ${err.message}`);
4526
+ return JSON.parse(raw);
4527
+ } catch {
4528
+ const repaired = tryRepairJson(raw);
4529
+ if (repaired) {
4530
+ try {
4531
+ return JSON.parse(repaired);
4532
+ } catch {}
4533
+ }
4534
+ console.error(`[llm-openrouter] failed to parse tool arguments (raw len=${raw.length}, head=${JSON.stringify(raw.slice(0, 80))})`);
4498
4535
  return null;
4499
4536
  }
4500
4537
  }
@@ -4511,6 +4548,56 @@ async function callOpenRouterTool(params) {
4511
4548
  }
4512
4549
  return null;
4513
4550
  }
4551
+ function tryRepairJson(raw) {
4552
+ let s = raw.trim();
4553
+ if (!s)
4554
+ return null;
4555
+ const fence = s.match(/^```(?:json)?\s*([\s\S]*?)\s*```\s*$/);
4556
+ if (fence?.[1])
4557
+ s = fence[1].trim();
4558
+ if (!s.startsWith("{") && !s.startsWith("["))
4559
+ return null;
4560
+ let depthObj = 0;
4561
+ let depthArr = 0;
4562
+ let inString = false;
4563
+ let isEscaped = false;
4564
+ for (let i = 0;i < s.length; i++) {
4565
+ const ch = s[i];
4566
+ if (isEscaped) {
4567
+ isEscaped = false;
4568
+ continue;
4569
+ }
4570
+ if (ch === "\\") {
4571
+ isEscaped = true;
4572
+ continue;
4573
+ }
4574
+ if (ch === '"') {
4575
+ inString = !inString;
4576
+ continue;
4577
+ }
4578
+ if (inString)
4579
+ continue;
4580
+ if (ch === "{")
4581
+ depthObj++;
4582
+ else if (ch === "}")
4583
+ depthObj--;
4584
+ else if (ch === "[")
4585
+ depthArr++;
4586
+ else if (ch === "]")
4587
+ depthArr--;
4588
+ }
4589
+ if (inString)
4590
+ s += '"';
4591
+ while (depthArr > 0) {
4592
+ s += "]";
4593
+ depthArr--;
4594
+ }
4595
+ while (depthObj > 0) {
4596
+ s += "}";
4597
+ depthObj--;
4598
+ }
4599
+ return s;
4600
+ }
4514
4601
  async function callMessage(params) {
4515
4602
  const provider = getProvider();
4516
4603
  if (provider === "anthropic")
@@ -4794,15 +4881,55 @@ function compactHtmlForRecovery(html, maxChars = 12000) {
4794
4881
  return html.slice(0, maxChars);
4795
4882
  }
4796
4883
  }
4884
+ var RECOVERY_SYSTEM_PROMPT = `
4885
+ Você ajuda a recuperar passos de teste E2E (Playwright) que falharam contra sites de e-commerce.
4886
+
4887
+ Receba o nome do step, a ação desejada e o HTML compactado da página ATUAL. Sugira UM seletor + ação ('click' | 'fill' | 'press') que provavelmente funciona NESTE HTML específico.
4888
+
4889
+ REGRAS CRÍTICAS:
4890
+
4891
+ 1. **Baseie no HTML fornecido.** Encontre o elemento literal no markup. Confirme mentalmente que o seletor que você vai retornar casa um elemento VISÍVEL no HTML compactado.
4892
+
4893
+ 2. **Se o step é 'go-checkout' ou 'go-checkout-retry':**
4894
+ - O alvo é um BOTÃO/LINK CTA com texto visível contendo "Finalizar", "Finalizar compra", "Continuar", "Continuar para pagamento", "Ir para o checkout", "Checkout", "Concluir", "Pagar", "Ir para o pagamento", "Avançar", "Próxima etapa".
4895
+ - **NUNCA** sugira o ícone genérico de carrinho do HEADER (texto curto "0", "1", badge counter, ou aria-label "Carrinho" sozinho). Esse é o gatilho que ABRE o drawer/cart-page, não o que finaliza compra.
4896
+ - **Selectors com href de checkout (\`a[href*="checkout"]\`, \`a[href="/checkout/#/cart?v=1"]\`) PODEM funcionar** mas SOMENTE quando qualificados — ou seja, garanta uma das duas:
4897
+ a. Qualificação por **texto**: \`a[href*="checkout"]:has-text('Finalizar')\`, \`a[href*="checkout"]:has-text('Continuar')\`
4898
+ b. Qualificação por **scope**: \`[role='dialog'] a[href*="checkout"]\`, \`[class*='minicart' i] a[href*="checkout"]\`, \`.cart-drawer a[href*="checkout"]\`
4899
+ Selectors GENÉRICOS sem qualificador (apenas \`a[href*="checkout"]\` sozinho) casam o ícone do header e devem ser EVITADOS.
4900
+ - Se NENHUM elemento com texto/scope plausível existe no HTML, retorne selector="" para sinalizar incerteza honestamente.
4901
+
4902
+ 3. **Se o step é 'shipping-calc-cart' ou 'shipping-calc-pdp':**
4903
+ - O alvo é um \`<input>\` (não button/link) visível com label/placeholder mencionando "CEP", "Frete", "Calcular", "Entrega", "Zip".
4904
+ - Prefira inputs cujo container/pai está visível (não escondido em accordion fechado).
4905
+
4906
+ 4. **Seletores preferidos** (nesta ordem):
4907
+ - Text-based Playwright: \`button:has-text('Finalizar')\`, \`a:has-text('Continuar para pagamento')\`
4908
+ - data-attributes específicos: \`[data-checkout-button]\`, \`[data-cart-finalize]\`, \`[data-fs-cart-button]\`
4909
+ - ARIA: \`[aria-label*='finalizar' i]\`, \`[role='button'][aria-label*='checkout' i]\`
4910
+ - Classes específicas de plataforma: \`.vtex-minicart-2-x-checkoutButton\`, \`.fs-cart__checkout\`
4911
+ - Por último, escopo hierárquico curto: \`[role='dialog'] button.cta\`, \`.minicart-drawer button[type='submit']\`
4912
+
4913
+ 5. **EVITE:**
4914
+ - IDs hash-gerados (\`#r-abc123\`)
4915
+ - Tailwind utility classes encadeadas (\`.flex.items-center.bg-blue\`)
4916
+ - Seletores genéricos sem qualificador (\`a[href*=...]\`, \`button[type=submit]\` sozinho)
4917
+ - Seletores que casam o ELEMENTO ERRADO em outra parte do DOM (ex: link de "checkout your reviews" no footer)
4918
+ - Seletores muito profundos (>4 níveis hierárquicos)
4919
+
4920
+ 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.
4921
+
4922
+ Responda SEMPRE via tool_use suggest_recovery.
4923
+ `.trim();
4797
4924
  async function suggestRecovery(input) {
4798
4925
  const compacted = compactHtmlForRecovery(input.html);
4799
4926
  const inp = await callTool({
4800
- systemPrompt: "Você ajuda a recuperar passos de teste E2E (Playwright) que falharam. Receba o nome do step, a ação desejada e o HTML atual da página. Sugira UM seletor e ação ('click' | 'fill' | 'press') que provavelmente funciona. Para 'fill', preferir input visível com label relacionada. Para 'click', preferir botão visível com texto/label que case com a intenção. Use Playwright selectors quando apropriado (`button:has-text('X')`, `[role='button']`). Não invente seletores genéricos; baseie no HTML fornecido.",
4927
+ systemPrompt: RECOVERY_SYSTEM_PROMPT,
4801
4928
  userText: `Step: ${input.stepName}
4802
4929
  Ação desejada: ${input.intendedAction}
4803
- ${input.alreadyTried?.length ? `Já tentei: ${input.alreadyTried.join(", ")}
4930
+ ${input.alreadyTried?.length ? `Já tentei (não repita estes): ${input.alreadyTried.join(", ")}
4804
4931
  ` : ""}
4805
- HTML compactado:
4932
+ HTML compactado da página NESTE momento:
4806
4933
  \`\`\`html
4807
4934
  ${compacted}
4808
4935
  \`\`\``,
@@ -4813,9 +4940,9 @@ ${compacted}
4813
4940
  inputSchema: RECOVER_STEP_INPUT_SCHEMA
4814
4941
  }
4815
4942
  });
4816
- if (inp?.selector && inp.action) {
4943
+ if (inp?.selector?.trim() && inp.action) {
4817
4944
  return {
4818
- selector: inp.selector,
4945
+ selector: inp.selector.trim(),
4819
4946
  action: inp.action,
4820
4947
  value: inp.value,
4821
4948
  reasoning: inp.reasoning
@@ -5056,6 +5183,15 @@ function isResolutionContext(v) {
5056
5183
 
5057
5184
  // src/engine/flows.ts
5058
5185
  var PURCHASE_JOURNEY_TOTAL_STEPS = 8;
5186
+ var DEBUG_PARITY2 = process.env.DEBUG_PARITY === "1" || process.env.DEBUG_PARITY === "true";
5187
+ var DEBUG_START2 = Date.now();
5188
+ function dlog2(ctx, msg) {
5189
+ if (!DEBUG_PARITY2)
5190
+ return;
5191
+ const elapsed = ((Date.now() - DEBUG_START2) / 1000).toFixed(1);
5192
+ process.stderr.write(`[+${elapsed}s ${ctx.viewport}/${ctx.side}] ${msg}
5193
+ `);
5194
+ }
5059
5195
  function selFor(ctx, key) {
5060
5196
  return selectorsFor(key, { rc: ctx.rc, learned: ctx.learned, platform: ctx.platform });
5061
5197
  }
@@ -5094,7 +5230,7 @@ async function runFlow(flow, ctx) {
5094
5230
  resolve(finalize(flow, ctx, [], [
5095
5231
  {
5096
5232
  step: 0,
5097
- name: "visit-home",
5233
+ name: "flow-timeout",
5098
5234
  side: ctx.side,
5099
5235
  viewport: ctx.viewport,
5100
5236
  status: "failed",
@@ -5103,7 +5239,14 @@ async function runFlow(flow, ctx) {
5103
5239
  actionDescription: `[flow-timeout] flow "${flow}" excedeu ${deadlineMs}ms — captura abortada pela safety net externa, ${pages.length} page(s) fechada(s) para liberar o contexto. Step interno provavelmente travou em uma operação Playwright que não respeitou seu timeout declarado.`
5104
5240
  }
5105
5241
  ], start));
5106
- cleanup = Promise.allSettled(pages.map((p) => p.close()));
5242
+ const CLOSE_CAP_MS = 5000;
5243
+ const cappedClose = (p) => Promise.race([
5244
+ p.close().catch(() => {
5245
+ return;
5246
+ }),
5247
+ new Promise((resolve2) => setTimeout(resolve2, CLOSE_CAP_MS))
5248
+ ]);
5249
+ cleanup = Promise.allSettled(pages.map(cappedClose));
5107
5250
  }, deadlineMs);
5108
5251
  });
5109
5252
  try {
@@ -5219,12 +5362,15 @@ async function flowPurchaseJourney(ctx) {
5219
5362
  };
5220
5363
  try {
5221
5364
  reportStart(1, "visit-home");
5365
+ dlog2(ctx, `step 1 visit-home: capturePage(${ctx.baseUrl}) start (scrollToLoad=false)`);
5222
5366
  const homeCap = await capturePage(page, {
5223
5367
  url: ctx.baseUrl,
5224
5368
  side: ctx.side,
5225
5369
  viewport: ctx.viewport,
5226
- screenshotPath: screenshotPath(ctx, "pj-1-home")
5370
+ screenshotPath: screenshotPath(ctx, "pj-1-home"),
5371
+ scrollToLoad: false
5227
5372
  });
5373
+ dlog2(ctx, `step 1 visit-home: capturePage done status=${homeCap.status}`);
5228
5374
  pages.push(homeCap);
5229
5375
  const step1Status = homeCap.status >= 200 && homeCap.status < 400 ? "ok" : "failed";
5230
5376
  steps.push({
@@ -5243,19 +5389,24 @@ async function flowPurchaseJourney(ctx) {
5243
5389
  return { pages, steps };
5244
5390
  }
5245
5391
  reportStart(2, "navigate-plp");
5392
+ dlog2(ctx, `step 2 navigate-plp: findCategoryUrl start (hint=${ctx.rc.plpUrlHint ?? "—"})`);
5246
5393
  const plpHit = ctx.rc.plpUrlHint ? { url: new URL(ctx.rc.plpUrlHint, ctx.baseUrl).toString(), selector: "__hint__" } : await findCategoryUrl(page, ctx);
5394
+ dlog2(ctx, `step 2 navigate-plp: findCategoryUrl done → ${plpHit?.url ?? "null"}`);
5247
5395
  if (!plpHit) {
5248
5396
  steps.push(makeSkipStep(2, "navigate-plp", ctx, "no category link found"));
5249
5397
  reportEnd(2, "navigate-plp", "skipped", 0, "no category link found");
5250
5398
  return { pages, steps };
5251
5399
  }
5252
5400
  const t2 = Date.now();
5401
+ dlog2(ctx, `step 2 navigate-plp: capturePage(${plpHit.url}) start (scrollToLoad=false)`);
5253
5402
  const plpCap = await capturePage(page, {
5254
5403
  url: plpHit.url,
5255
5404
  side: ctx.side,
5256
5405
  viewport: ctx.viewport,
5257
- screenshotPath: screenshotPath(ctx, "pj-2-plp")
5406
+ screenshotPath: screenshotPath(ctx, "pj-2-plp"),
5407
+ scrollToLoad: false
5258
5408
  });
5409
+ dlog2(ctx, `step 2 navigate-plp: capturePage done status=${plpCap.status}`);
5259
5410
  pages.push(plpCap);
5260
5411
  const step2Status = plpCap.status >= 200 && plpCap.status < 400 ? "ok" : "failed";
5261
5412
  steps.push({
@@ -5285,7 +5436,8 @@ async function flowPurchaseJourney(ctx) {
5285
5436
  url: pdpHit.url,
5286
5437
  side: ctx.side,
5287
5438
  viewport: ctx.viewport,
5288
- screenshotPath: screenshotPath(ctx, "pj-3-pdp")
5439
+ screenshotPath: screenshotPath(ctx, "pj-3-pdp"),
5440
+ scrollToLoad: false
5289
5441
  });
5290
5442
  pages.push(pdpCap);
5291
5443
  const step3Status = pdpCap.status >= 200 && pdpCap.status < 400 ? "ok" : "failed";
@@ -5304,6 +5456,14 @@ async function flowPurchaseJourney(ctx) {
5304
5456
  reportEnd(3, "enter-pdp", step3Status, Date.now() - t3);
5305
5457
  steps[steps.length - 1].actionDescription = `Abriu PDP \`${pdpHit.url}\` (via \`${pdpHit.selector}\`)`;
5306
5458
  steps[steps.length - 1].beforeUrl = plpHit.url;
5459
+ const expectedProductTitle = await extractProductTitle(page);
5460
+ dlog2(ctx, `step 3 enter-pdp: extracted product title → ${expectedProductTitle ? `"${expectedProductTitle.slice(0, 60)}"` : "null"}`);
5461
+ if (expectedProductTitle) {
5462
+ steps[steps.length - 1].detail = {
5463
+ ...steps[steps.length - 1].detail ?? {},
5464
+ productTitle: expectedProductTitle
5465
+ };
5466
+ }
5307
5467
  reportStart(4, "shipping-calc-pdp");
5308
5468
  let cepInputPdp = await firstVisible(page, selFor(ctx, "cepInputPdp"));
5309
5469
  let cepPdpRecovered = false;
@@ -5415,35 +5575,68 @@ async function flowPurchaseJourney(ctx) {
5415
5575
  recoveryBudget--;
5416
5576
  }
5417
5577
  }
5578
+ let cartOpenMethod = "failed";
5418
5579
  let miniText = "";
5419
5580
  if (miniHit) {
5420
5581
  miniText = await miniHit.locator.innerText().catch(() => "");
5421
- await miniHit.locator.click({ timeout: 3000 }).catch(() => {
5422
- return;
5423
- });
5424
- await page.waitForTimeout(1500);
5582
+ const openResult = await openMinicart(page, miniHit, ctx, expectedProductTitle);
5583
+ cartOpenMethod = openResult.method;
5584
+ } else {
5585
+ const alreadyOpen = await isCartRevealed(page, expectedProductTitle);
5586
+ if (alreadyOpen) {
5587
+ cartOpenMethod = "already-open";
5588
+ dlog2(ctx, `step 6 open-minicart: no trigger, but drawer already visible (matched ${alreadyOpen})`);
5589
+ }
5590
+ }
5591
+ let step6Validation;
5592
+ if (expectedProductTitle && cartOpenMethod !== "failed") {
5593
+ const v = await validateCartContainsTitle(page, expectedProductTitle, ctx);
5594
+ let reasonText;
5595
+ if (!v.found) {
5596
+ if (v.observedTitles.length === 0) {
5597
+ const emptyBanner = await detectEmptyCartBanner(page);
5598
+ reasonText = emptyBanner ? `cart genuinely empty — upstream add-to-cart didn't persist (session/cookie may not have carried over). Banner observed: "${emptyBanner}"` : "no cart line items visible after open (selectors may not match markup)";
5599
+ } else {
5600
+ reasonText = `expected title not found among ${v.observedTitles.length} observed`;
5601
+ }
5602
+ }
5603
+ step6Validation = {
5604
+ expectedTitle: expectedProductTitle,
5605
+ found: v.found,
5606
+ method: v.method,
5607
+ observedTitles: v.observedTitles.slice(0, 8),
5608
+ reason: reasonText
5609
+ };
5610
+ dlog2(ctx, `step 6 open-minicart: cart validation → found=${v.found} (${v.method})${reasonText ? ` — ${reasonText.slice(0, 80)}` : ""}`);
5611
+ } else if (!expectedProductTitle) {
5612
+ dlog2(ctx, "step 6 open-minicart: skipping validation — no PDP title captured");
5613
+ } else {
5614
+ dlog2(ctx, "step 6 open-minicart: skipping validation — cart UI not revealed");
5425
5615
  }
5426
5616
  const sp6 = screenshotPath(ctx, "pj-6-minicart");
5427
5617
  await page.screenshot({ path: sp6, fullPage: false }).catch(() => {
5428
5618
  return;
5429
5619
  });
5620
+ const step6Status = cartOpenMethod === "failed" ? "failed" : step6Validation && !step6Validation.found ? "failed" : "ok";
5430
5621
  steps.push({
5431
5622
  step: 6,
5432
5623
  name: "open-minicart",
5433
5624
  side: ctx.side,
5434
5625
  viewport: ctx.viewport,
5435
- status: "ok",
5626
+ status: step6Status,
5436
5627
  durationMs: Date.now() - t6,
5437
5628
  url: page.url(),
5438
5629
  screenshotPath: sp6,
5439
5630
  screenshotBeforePath: spBefore6,
5440
5631
  beforeUrl: beforeUrl6,
5441
- actionDescription: miniHit ? `Clicou no trigger do minicart${miniText ? ` '${miniText.slice(0, 30).trim()}'` : ""} (\`${miniHit.selector}\`)${miniRecovered ? " — selector via recovery LLM" : ""}` : "Minicart já aberto após add-to-cart (drawer/popup)",
5632
+ cartOpenMethod,
5633
+ cartValidation: step6Validation,
5634
+ actionDescription: miniHit ? `Abriu minicart via ${cartOpenMethod}${miniText ? ` em '${miniText.slice(0, 30).trim()}'` : ""} (\`${miniHit.selector}\`)${miniRecovered ? " — selector via recovery LLM" : ""}${step6Validation ? step6Validation.found ? " ✓ produto encontrado no cart" : ` ✗ produto ESPERADO não encontrado (${step6Validation.observedTitles?.length ?? 0} itens observados)` : ""}` : cartOpenMethod === "already-open" ? "Minicart já estava aberto após add-to-cart" : "Minicart não pôde ser aberto",
5442
5635
  selectorKey: miniHit ? "minicartTrigger" : undefined,
5443
5636
  usedSelector: miniHit?.selector,
5444
5637
  recoveredByLlm: miniRecovered || undefined
5445
5638
  });
5446
- reportEnd(6, "open-minicart", "ok", Date.now() - t6);
5639
+ reportEnd(6, "open-minicart", step6Status, Date.now() - t6);
5447
5640
  reportStart(7, "shipping-calc-cart");
5448
5641
  let cepInputCart = await firstVisible(page, selFor(ctx, "cepInputCart"));
5449
5642
  let cepCartRecovered = false;
@@ -5491,10 +5684,71 @@ async function flowPurchaseJourney(ctx) {
5491
5684
  reportEnd(7, "shipping-calc-cart", "skipped", 0, "no CEP input in cart");
5492
5685
  }
5493
5686
  reportStart(8, "go-checkout");
5494
- let checkoutHit = await firstVisibleLocator(page, selFor(ctx, "checkoutButton"));
5687
+ const beforeStep8Url = page.url();
5688
+ const alreadyInCheckoutPage = /\/(checkout|cart|carrinho)(\/|#|$|\?)/i.test(beforeStep8Url);
5689
+ dlog2(ctx, `step 8 go-checkout: beforeUrl=${beforeStep8Url} alreadyInCheckoutPage=${alreadyInCheckoutPage}`);
5690
+ if (!alreadyInCheckoutPage) {
5691
+ const drawerStillOpen = await firstVisible(page, [
5692
+ "[role='dialog']:visible",
5693
+ "[aria-modal='true']:visible",
5694
+ "[data-minicart][aria-hidden='false']",
5695
+ ".minicart--open",
5696
+ ".minicart-drawer:not([hidden])",
5697
+ "[class*='minicart'][class*='open']",
5698
+ "[class*='cart-drawer'][class*='open']"
5699
+ ]);
5700
+ if (!drawerStillOpen) {
5701
+ dlog2(ctx, "step 8 go-checkout: drawer appears closed, re-clicking minicart trigger");
5702
+ const reTrigger = await firstVisibleLocator(page, selFor(ctx, "minicartTrigger"));
5703
+ if (reTrigger) {
5704
+ await reTrigger.locator.click({ timeout: 3000 }).catch(() => {
5705
+ return;
5706
+ });
5707
+ await page.waitForTimeout(1500);
5708
+ dlog2(ctx, `step 8 go-checkout: re-opened drawer via ${reTrigger.selector}`);
5709
+ } else {
5710
+ dlog2(ctx, "step 8 go-checkout: minicartTrigger not found — drawer state unknown");
5711
+ }
5712
+ } else {
5713
+ dlog2(ctx, `step 8 go-checkout: drawer still open (matched ${drawerStillOpen})`);
5714
+ }
5715
+ }
5716
+ const nextStepSelectors = alreadyInCheckoutPage ? [
5717
+ "button:has-text('Continuar para pagamento')",
5718
+ "a:has-text('Continuar para pagamento')",
5719
+ "button:has-text('Ir para o pagamento')",
5720
+ "a:has-text('Ir para o pagamento')",
5721
+ "button:has-text('Continuar')",
5722
+ "a:has-text('Continuar')",
5723
+ "button:has-text('Avançar')",
5724
+ "a:has-text('Avançar')",
5725
+ "button:has-text('Próxima etapa')",
5726
+ "a:has-text('Próxima etapa')",
5727
+ "button:has-text('Confirmar')",
5728
+ "button:has-text('Finalizar')",
5729
+ "a:has-text('Finalizar')",
5730
+ "#cart-to-orderform",
5731
+ "#btn-go-to-payment",
5732
+ ".btn-go-to-payment",
5733
+ "a.btn-place-order",
5734
+ "a.orange-btn",
5735
+ ".cart-links-bottom a",
5736
+ "button[type='submit'][class*='checkout' i]",
5737
+ "button[type='submit'][class*='continue' i]",
5738
+ "[data-checkout-next]",
5739
+ "[data-fs-cart-checkout-button]"
5740
+ ] : [];
5741
+ const baseSelectors = selFor(ctx, "checkoutButton");
5742
+ const checkoutSelectors = [...nextStepSelectors, ...baseSelectors];
5743
+ dlog2(ctx, `step 8 go-checkout: firstVisibleLocator on ${checkoutSelectors.length} selectors${alreadyInCheckoutPage ? ` (${nextStepSelectors.length} next-step prefixed)` : ""}`);
5744
+ let checkoutHit = await firstVisibleLocator(page, checkoutSelectors);
5745
+ dlog2(ctx, `step 8 go-checkout: default match → ${checkoutHit ? `selector=${checkoutHit.selector}` : "null"}`);
5495
5746
  let checkoutRecovered = false;
5496
5747
  if (!checkoutHit && recoveryBudget > 0) {
5497
- const recovery = await attemptRecovery(page, ctx, "go-checkout", "Clicar no botão 'Finalizar compra' / 'Ir para o checkout'", selFor(ctx, "checkoutButton"));
5748
+ const recoveryIntent = alreadyInCheckoutPage ? "Já estou no checkout (URL contém /checkout ou /cart). Achar o botão visível que avança pra próxima etapa do checkout (geralmente 'Continuar para pagamento', 'Continuar', 'Avançar', 'Próxima etapa', 'Confirmar', ou similar). NÃO é o ícone do header — é uma CTA primary na página." : "Clicar no botão 'Finalizar compra' / 'Ir para o checkout' / 'Finalizar' dentro do drawer/popup do carrinho aberto.";
5749
+ dlog2(ctx, `step 8 go-checkout: defaults missed, calling attemptRecovery (budget=${recoveryBudget}, mode=${alreadyInCheckoutPage ? "advance-checkout" : "drawer-finalize"})`);
5750
+ const recovery = await attemptRecovery(page, ctx, "go-checkout", recoveryIntent, checkoutSelectors);
5751
+ dlog2(ctx, `step 8 go-checkout: LLM recovery → ${recovery ? `selector=${recovery.selector}` : "null"}`);
5498
5752
  if (recovery) {
5499
5753
  checkoutHit = recovery;
5500
5754
  checkoutRecovered = true;
@@ -5502,6 +5756,7 @@ async function flowPurchaseJourney(ctx) {
5502
5756
  }
5503
5757
  }
5504
5758
  if (!checkoutHit) {
5759
+ dlog2(ctx, "step 8 go-checkout: no button found, skipping");
5505
5760
  steps.push(makeSkipStep(8, "go-checkout", ctx, "no checkout button found (recovery exhausted)"));
5506
5761
  reportEnd(8, "go-checkout", "skipped", 0, "no checkout button found");
5507
5762
  return { pages, steps };
@@ -5512,8 +5767,10 @@ async function flowPurchaseJourney(ctx) {
5512
5767
  return;
5513
5768
  });
5514
5769
  const clickedText2 = await hit.locator.innerText().catch(() => "");
5770
+ const urlBefore = page.url();
5771
+ const urlChangePredicate = alreadyInCheckoutPage ? (u) => u.toString() !== urlBefore : /checkout/i;
5515
5772
  await Promise.all([
5516
- page.waitForURL(/checkout/i, { timeout: 1e4 }).catch(() => {
5773
+ page.waitForURL(urlChangePredicate, { timeout: 1e4 }).catch(() => {
5517
5774
  return;
5518
5775
  }),
5519
5776
  hit.locator.click({ timeout: 5000 }).catch(() => {
@@ -5529,25 +5786,62 @@ async function flowPurchaseJourney(ctx) {
5529
5786
  };
5530
5787
  const t8 = Date.now();
5531
5788
  const beforeUrl8 = page.url();
5789
+ const CHECKOUT_URL_MARKERS = /\/(checkout|checkouts|finalize|finalizar|pagamento|payment|pedido|order|orderform|onepage|secure|seguro)(\/|#|$|\?|-)/i;
5790
+ const isReachedCheckout = (finalUrl) => {
5791
+ if (finalUrl === beforeUrl8)
5792
+ return false;
5793
+ return CHECKOUT_URL_MARKERS.test(finalUrl);
5794
+ };
5532
5795
  let attempt = 1;
5796
+ dlog2(ctx, `step 8 go-checkout: tryCheckoutClick attempt 1 — selector=${checkoutHit.selector}, beforeUrl=${beforeUrl8}`);
5533
5797
  let result = await tryCheckoutClick(checkoutHit, attempt);
5534
- let reachedCheckout = /\/checkout/i.test(result.url);
5798
+ let reachedCheckout = isReachedCheckout(result.url);
5535
5799
  let usedSelector = checkoutHit.selector;
5536
5800
  let clickedText = result.clickedText;
5537
- if (!reachedCheckout && recoveryBudget > 0 && !/\/checkout/i.test(beforeUrl8)) {
5538
- const retrySuggestion = await attemptRecovery(page, ctx, "go-checkout-retry", `Cliquei em '${clickedText.slice(0, 40).trim()}' (selector \`${usedSelector}\`), mas a URL ficou em ${result.url} e não foi pra /checkout. Achar o botão que de fato navega pra /checkout neste cart/minicart aberto.`, [usedSelector, ...selFor(ctx, "checkoutButton")]);
5801
+ dlog2(ctx, `step 8 go-checkout: attempt 1 result — finalUrl=${result.url} reached=${reachedCheckout} clicked='${clickedText.slice(0, 40).trim()}'`);
5802
+ if (!reachedCheckout && recoveryBudget > 0) {
5803
+ dlog2(ctx, `step 8 go-checkout: attempt 1 missed target — calling attemptRecovery for retry (budget=${recoveryBudget}, mode=${alreadyInCheckoutPage ? "advance-checkout" : "drawer-finalize"})`);
5804
+ const retryIntent = alreadyInCheckoutPage ? `Já estou no checkout, em ${beforeUrl8}. Cliquei em '${clickedText.slice(0, 40).trim()}' (selector \`${usedSelector}\`), mas a URL não mudou ou foi pra ${result.url}. Achar o botão que avança pra próxima etapa do checkout (Continuar para pagamento, Avançar, Próxima etapa, Confirmar, etc).` : `Cliquei em '${clickedText.slice(0, 40).trim()}' (selector \`${usedSelector}\`), mas a URL ficou em ${result.url} e não foi pra /checkout. Achar o botão que de fato navega pra /checkout neste cart/minicart aberto.`;
5805
+ const retrySuggestion = await attemptRecovery(page, ctx, "go-checkout-retry", retryIntent, [usedSelector, ...checkoutSelectors]);
5806
+ dlog2(ctx, `step 8 go-checkout: retry LLM suggestion → ${retrySuggestion ? `selector=${retrySuggestion.selector}` : "null"}`);
5539
5807
  if (retrySuggestion) {
5540
5808
  recoveryBudget--;
5541
5809
  checkoutRecovered = true;
5542
5810
  attempt++;
5811
+ dlog2(ctx, `step 8 go-checkout: tryCheckoutClick attempt 2 — selector=${retrySuggestion.selector}`);
5543
5812
  const retryResult = await tryCheckoutClick(retrySuggestion, attempt);
5544
5813
  result = retryResult;
5545
5814
  usedSelector = retrySuggestion.selector;
5546
5815
  clickedText = retryResult.clickedText;
5547
- reachedCheckout = /\/checkout/i.test(retryResult.url);
5816
+ reachedCheckout = isReachedCheckout(retryResult.url);
5817
+ dlog2(ctx, `step 8 go-checkout: attempt 2 result — finalUrl=${retryResult.url} reached=${reachedCheckout} clicked='${retryResult.clickedText.slice(0, 40).trim()}'`);
5548
5818
  }
5819
+ } else if (!reachedCheckout) {
5820
+ dlog2(ctx, `step 8 go-checkout: not retrying — recoveryBudget=${recoveryBudget}`);
5549
5821
  }
5550
- const step8Status = reachedCheckout ? "ok" : "failed";
5822
+ let step8Validation;
5823
+ if (expectedProductTitle && reachedCheckout) {
5824
+ await page.waitForTimeout(1500);
5825
+ const v = await validateCartContainsTitle(page, expectedProductTitle, ctx);
5826
+ let reasonText;
5827
+ if (!v.found) {
5828
+ if (v.observedTitles.length === 0) {
5829
+ const emptyBanner = await detectEmptyCartBanner(page);
5830
+ reasonText = emptyBanner ? `checkout shows empty cart banner — upstream session lost. Banner: "${emptyBanner}"` : "checkout page has no visible line items (selectors may not match markup)";
5831
+ } else {
5832
+ reasonText = `expected title not found among ${v.observedTitles.length} observed on checkout`;
5833
+ }
5834
+ }
5835
+ step8Validation = {
5836
+ expectedTitle: expectedProductTitle,
5837
+ found: v.found,
5838
+ method: v.method,
5839
+ observedTitles: v.observedTitles.slice(0, 8),
5840
+ reason: reasonText
5841
+ };
5842
+ dlog2(ctx, `step 8 go-checkout: checkout validation → found=${v.found} (${v.method})${reasonText ? ` — ${reasonText.slice(0, 80)}` : ""}`);
5843
+ }
5844
+ const step8Status = reachedCheckout ? step8Validation && !step8Validation.found ? "failed" : "ok" : "failed";
5551
5845
  steps.push({
5552
5846
  step: 8,
5553
5847
  name: "go-checkout",
@@ -5559,7 +5853,8 @@ async function flowPurchaseJourney(ctx) {
5559
5853
  screenshotPath: result.spAfter,
5560
5854
  screenshotBeforePath: result.spBefore,
5561
5855
  beforeUrl: beforeUrl8,
5562
- actionDescription: `Clicou em${clickedText ? ` '${clickedText.slice(0, 30).trim()}'` : ""} (\`${usedSelector}\`); URL final: ${result.url}${reachedCheckout ? " ✓ atingiu /checkout" : " ✗ não foi pra checkout"}${attempt > 1 ? ` (após ${attempt} tentativas com recovery LLM)` : ""}`,
5856
+ cartValidation: step8Validation,
5857
+ actionDescription: `Clicou em${clickedText ? ` '${clickedText.slice(0, 30).trim()}'` : ""} (\`${usedSelector}\`); URL final: ${result.url}${reachedCheckout ? " ✓ atingiu /checkout" : " ✗ não foi pra checkout"}${attempt > 1 ? ` (após ${attempt} tentativas com recovery LLM)` : ""}${step8Validation ? step8Validation.found ? " ✓ produto persiste no checkout" : ` ✗ produto ESPERADO ausente no checkout (${step8Validation.observedTitles?.length ?? 0} itens observados)` : ""}`,
5563
5858
  selectorKey: "checkoutButton",
5564
5859
  usedSelector,
5565
5860
  recoveredByLlm: checkoutRecovered || undefined
@@ -5584,10 +5879,15 @@ function makeSkipStep(step, name, ctx, note) {
5584
5879
  }
5585
5880
  async function findCategoryUrl(page, ctx) {
5586
5881
  const selectors = selFor(ctx, "categoryLink");
5882
+ dlog2(ctx, ` findCategoryUrl: collectCandidateLinks(${selectors.length} selectors) start`);
5883
+ const t0 = Date.now();
5587
5884
  const candidates = await collectCandidateLinks(page, selectors, 12);
5885
+ dlog2(ctx, ` findCategoryUrl: collectCandidateLinks done in ${Date.now() - t0}ms → ${candidates.length} candidates`);
5588
5886
  if (candidates.length === 0)
5589
5887
  return null;
5888
+ dlog2(ctx, " findCategoryUrl: pickCategoryLink LLM call start");
5590
5889
  const picked = await pickCategoryLink(candidates.map((c) => ({ text: c.text, href: c.href })));
5890
+ dlog2(ctx, ` findCategoryUrl: pickCategoryLink LLM done → ${picked?.href ?? "null"}`);
5591
5891
  if (!picked)
5592
5892
  return null;
5593
5893
  const original = candidates.find((c) => c.href === picked.href);
@@ -5636,20 +5936,34 @@ async function firstVisibleLocator(page, selectors) {
5636
5936
  }
5637
5937
  return null;
5638
5938
  }
5939
+ var COLLECT_CANDIDATES_BUDGET_MS = 15000;
5940
+ function withCap(p, capMs, fallback) {
5941
+ return Promise.race([
5942
+ p.catch(() => fallback),
5943
+ new Promise((resolve) => setTimeout(() => resolve(fallback), capMs))
5944
+ ]);
5945
+ }
5639
5946
  async function collectCandidateLinks(page, selectors, limit = 12) {
5640
5947
  const out = [];
5641
5948
  const seenHrefs = new Set;
5949
+ const deadline = Date.now() + COLLECT_CANDIDATES_BUDGET_MS;
5950
+ const expired = () => Date.now() >= deadline;
5642
5951
  for (const sel of selectors) {
5643
5952
  if (out.length >= limit)
5644
5953
  break;
5954
+ if (expired())
5955
+ break;
5645
5956
  try {
5646
5957
  const elements = page.locator(sel);
5647
- const count = await elements.count();
5958
+ const count = await withCap(elements.count(), 1000, 0);
5648
5959
  for (let i = 0;i < count && out.length < limit; i++) {
5960
+ if (expired())
5961
+ break;
5649
5962
  const el = elements.nth(i);
5650
- if (!await el.isVisible({ timeout: 250 }).catch(() => false))
5963
+ const visible = await withCap(el.isVisible({ timeout: 250 }).catch(() => false), 400, false);
5964
+ if (!visible)
5651
5965
  continue;
5652
- const href = await el.getAttribute("href").catch(() => null);
5966
+ const href = await withCap(el.getAttribute("href").catch(() => null), 400, null);
5653
5967
  if (!href)
5654
5968
  continue;
5655
5969
  let abs = href;
@@ -5661,7 +5975,7 @@ async function collectCandidateLinks(page, selectors, limit = 12) {
5661
5975
  if (seenHrefs.has(abs))
5662
5976
  continue;
5663
5977
  seenHrefs.add(abs);
5664
- const text = (await el.innerText().catch(() => "")).slice(0, 60).trim();
5978
+ const text = (await withCap(el.innerText().catch(() => ""), 400, "")).slice(0, 60).trim();
5665
5979
  out.push({ text, href: abs, selector: sel });
5666
5980
  }
5667
5981
  } catch {}
@@ -5680,6 +5994,386 @@ async function fillCep(page, selector, cep) {
5680
5994
  return false;
5681
5995
  }
5682
5996
  }
5997
+ var GENERIC_TITLE_PATTERNS = [
5998
+ /^p[áa]gina de produto/i,
5999
+ /^product page/i,
6000
+ /^carregando/i,
6001
+ /^loading/i,
6002
+ /^undefined/i,
6003
+ /^home$/i
6004
+ ];
6005
+ function looksGeneric(s) {
6006
+ const t = s.trim();
6007
+ return GENERIC_TITLE_PATTERNS.some((re) => re.test(t));
6008
+ }
6009
+ async function extractProductTitle(page) {
6010
+ await page.waitForSelector("h1", { timeout: 2500, state: "attached" }).catch(() => {
6011
+ return;
6012
+ });
6013
+ const visibleSelectors = [
6014
+ "main h1",
6015
+ "h1[class*='product' i]",
6016
+ "h1.product-title",
6017
+ "[itemprop='name'][data-product-name]",
6018
+ "[data-product-name]",
6019
+ "[data-fs-product-title]",
6020
+ "[itemprop='name']",
6021
+ ".vtex-store-components-3-x-productNameContainer",
6022
+ "h1"
6023
+ ];
6024
+ for (const sel of visibleSelectors) {
6025
+ try {
6026
+ const el = page.locator(sel).first();
6027
+ const visible = await withCap(el.isVisible({ timeout: 250 }).catch(() => false), 400, false);
6028
+ if (!visible)
6029
+ continue;
6030
+ const text = await withCap(el.innerText().catch(() => ""), 500, "");
6031
+ const clean = text.trim();
6032
+ if (clean.length > 3 && !looksGeneric(clean))
6033
+ return clean;
6034
+ } catch {}
6035
+ }
6036
+ try {
6037
+ const jsonLdName = await withCap(page.evaluate(() => {
6038
+ const scripts = Array.from(document.querySelectorAll('script[type="application/ld+json"]'));
6039
+ for (const s of scripts) {
6040
+ try {
6041
+ const data = JSON.parse(s.textContent ?? "{}");
6042
+ const items = Array.isArray(data) ? data : [data];
6043
+ for (const item of items) {
6044
+ const product = item?.["@graph"]?.find?.((x) => x?.["@type"] === "Product") ?? item;
6045
+ if (product?.["@type"] === "Product" && typeof product.name === "string") {
6046
+ return product.name;
6047
+ }
6048
+ }
6049
+ } catch {}
6050
+ }
6051
+ return null;
6052
+ }), 1000, null);
6053
+ if (jsonLdName && jsonLdName.trim().length > 3 && !looksGeneric(jsonLdName)) {
6054
+ return jsonLdName.trim();
6055
+ }
6056
+ } catch {}
6057
+ try {
6058
+ const og = await withCap(page.locator("meta[property='og:title']").first().getAttribute("content").catch(() => null), 500, null);
6059
+ if (og && og.trim().length > 3 && !looksGeneric(og))
6060
+ return og.trim();
6061
+ } catch {}
6062
+ const docTitle = await withCap(page.title().catch(() => ""), 500, "");
6063
+ if (docTitle.trim().length > 3 && !looksGeneric(docTitle))
6064
+ return docTitle.trim();
6065
+ return null;
6066
+ }
6067
+ async function isCartUiVisible(page) {
6068
+ return firstVisible(page, [
6069
+ "[role='dialog']:visible",
6070
+ "[aria-modal='true']:visible",
6071
+ "[data-minicart][aria-hidden='false']",
6072
+ "[data-minicart-open]",
6073
+ ".minicart--open",
6074
+ ".minicart-drawer:not([hidden])",
6075
+ "[class*='minicart'][class*='open']",
6076
+ "[class*='cart-drawer'][class*='open']",
6077
+ "[class*='drawer-cart']:visible"
6078
+ ]);
6079
+ }
6080
+ async function isCartRevealed(page, expectedProductTitle) {
6081
+ if (expectedProductTitle) {
6082
+ const v = await validateCartContainsTitleQuick(page, expectedProductTitle);
6083
+ if (v)
6084
+ return `title-found:${v}`;
6085
+ }
6086
+ return isCartUiVisible(page);
6087
+ }
6088
+ async function validateCartContainsTitleQuick(page, expectedTitle) {
6089
+ const quickSelectors = [
6090
+ "[role='dialog']",
6091
+ "[class*='minicart' i]",
6092
+ "[class*='cart' i]",
6093
+ "[class*='checkout' i]",
6094
+ "#cart-fixed",
6095
+ "table.cart-items"
6096
+ ];
6097
+ for (const scope of quickSelectors) {
6098
+ try {
6099
+ const scopeLoc = page.locator(scope);
6100
+ if (await withCap(scopeLoc.count(), 800, 0) === 0)
6101
+ continue;
6102
+ const text = await withCap(scopeLoc.first().innerText().catch(() => ""), 800, "");
6103
+ if (text && titlesMatch(text, expectedTitle))
6104
+ return scope;
6105
+ } catch {}
6106
+ }
6107
+ return null;
6108
+ }
6109
+ async function dismissOverlays(page, ctx) {
6110
+ const overlaySelectors = [
6111
+ "[class*='cookie' i][class*='banner' i]",
6112
+ "[class*='cookie' i][class*='consent' i]",
6113
+ "[id*='cookie' i][class*='banner' i]",
6114
+ "[role='alertdialog']:visible",
6115
+ "[class*='toast' i]:visible",
6116
+ "[class*='snackbar' i]:visible",
6117
+ "[class*='added-to-cart' i]:visible",
6118
+ "[class*='product-added' i]:visible"
6119
+ ];
6120
+ let dismissedAny = false;
6121
+ for (const sel of overlaySelectors) {
6122
+ try {
6123
+ const overlay = page.locator(sel).first();
6124
+ if (!await withCap(overlay.isVisible({ timeout: 200 }).catch(() => false), 400, false))
6125
+ continue;
6126
+ const closer = overlay.locator("button[aria-label*='close' i], button[aria-label*='fechar' i], button[class*='close' i], [data-close], [aria-label='Close']").first();
6127
+ if (await withCap(closer.isVisible({ timeout: 200 }).catch(() => false), 400, false)) {
6128
+ await closer.click({ timeout: 1500 }).catch(() => {
6129
+ return;
6130
+ });
6131
+ dismissedAny = true;
6132
+ continue;
6133
+ }
6134
+ await page.keyboard.press("Escape").catch(() => {
6135
+ return;
6136
+ });
6137
+ dismissedAny = true;
6138
+ } catch {}
6139
+ }
6140
+ if (dismissedAny) {
6141
+ dlog2(ctx, " openMinicart: dismissed overlay(s) before interacting");
6142
+ await page.waitForTimeout(500);
6143
+ }
6144
+ }
6145
+ async function waitForCartHydration(page) {
6146
+ await Promise.race([
6147
+ page.waitForResponse((r) => /\/api\/checkout\/pub\/orderForm|orderForm|cart\/api/i.test(r.url()) && r.ok(), { timeout: 8000 }).catch(() => {
6148
+ return;
6149
+ }),
6150
+ page.waitForSelector(".cart-items, [class*='cart-item' i], #cart-fixed .item, [data-cart-item]", { timeout: 8000 }).catch(() => {
6151
+ return;
6152
+ })
6153
+ ]);
6154
+ await page.waitForTimeout(800);
6155
+ }
6156
+ async function openMinicart(page, trigger, ctx, expectedProductTitle) {
6157
+ const beforeUrl = page.url();
6158
+ await dismissOverlays(page, ctx);
6159
+ await page.waitForTimeout(800);
6160
+ const alreadyOpen = await isCartRevealed(page, expectedProductTitle);
6161
+ if (alreadyOpen) {
6162
+ dlog2(ctx, ` openMinicart: already-open (matched ${alreadyOpen})`);
6163
+ return { method: "already-open", url: beforeUrl, visibleMarker: alreadyOpen };
6164
+ }
6165
+ const triggerHref = await trigger.locator.getAttribute("href").catch(() => null);
6166
+ const hrefHasCartTarget = !!triggerHref && /\/(checkout|cart|carrinho)/i.test(triggerHref);
6167
+ if (ctx.viewport === "desktop") {
6168
+ dlog2(ctx, ` openMinicart: trying hover first on ${trigger.selector}${triggerHref ? ` (href=${triggerHref})` : ""}`);
6169
+ await trigger.locator.hover({ timeout: 3000 }).catch(() => {
6170
+ return;
6171
+ });
6172
+ await page.waitForTimeout(1500);
6173
+ const hoverOpened = await isCartRevealed(page, expectedProductTitle);
6174
+ if (hoverOpened) {
6175
+ dlog2(ctx, ` openMinicart: hover opened drawer (${hoverOpened})`);
6176
+ return { method: "hover", url: page.url(), visibleMarker: hoverOpened };
6177
+ }
6178
+ }
6179
+ if (ctx.viewport === "mobile") {
6180
+ dlog2(ctx, ` openMinicart: trying tap (mobile) on ${trigger.selector}`);
6181
+ await Promise.all([
6182
+ page.waitForURL((url) => url.toString() !== beforeUrl, { timeout: 4000 }).catch(() => {
6183
+ return;
6184
+ }),
6185
+ trigger.locator.tap({ timeout: 4000 }).catch(() => {
6186
+ return;
6187
+ })
6188
+ ]);
6189
+ if (page.url() !== beforeUrl) {
6190
+ await page.waitForLoadState("load", { timeout: 8000 }).catch(() => {
6191
+ return;
6192
+ });
6193
+ await waitForCartHydration(page);
6194
+ dlog2(ctx, ` openMinicart: tap navigated → ${page.url()} (settled)`);
6195
+ return { method: "click-navigate", url: page.url(), visibleMarker: null };
6196
+ }
6197
+ await page.waitForTimeout(800);
6198
+ const tapOpened = await isCartRevealed(page, expectedProductTitle);
6199
+ if (tapOpened) {
6200
+ dlog2(ctx, ` openMinicart: tap opened drawer (${tapOpened})`);
6201
+ return { method: "click", url: page.url(), visibleMarker: tapOpened };
6202
+ }
6203
+ }
6204
+ dlog2(ctx, ` openMinicart: trying force-click on ${trigger.selector}${triggerHref ? ` (href=${triggerHref})` : ""}`);
6205
+ await Promise.all([
6206
+ page.waitForURL((url) => url.toString() !== beforeUrl, { timeout: 4000 }).catch(() => {
6207
+ return;
6208
+ }),
6209
+ trigger.locator.click({ force: true, timeout: 4000 }).catch(() => {
6210
+ return;
6211
+ })
6212
+ ]);
6213
+ const afterClickUrl = page.url();
6214
+ if (afterClickUrl !== beforeUrl) {
6215
+ await page.waitForLoadState("load", { timeout: 8000 }).catch(() => {
6216
+ return;
6217
+ });
6218
+ await waitForCartHydration(page);
6219
+ dlog2(ctx, ` openMinicart: click navigated → ${page.url()} (settled)`);
6220
+ return { method: "click-navigate", url: page.url(), visibleMarker: null };
6221
+ }
6222
+ await page.waitForTimeout(1500);
6223
+ const clickOpened = await isCartRevealed(page, expectedProductTitle);
6224
+ if (clickOpened) {
6225
+ dlog2(ctx, ` openMinicart: click opened drawer (${clickOpened})`);
6226
+ return { method: "click", url: afterClickUrl, visibleMarker: clickOpened };
6227
+ }
6228
+ if (ctx.viewport !== "desktop") {
6229
+ dlog2(ctx, ` openMinicart: click didn't reveal cart, trying hover (mobile)`);
6230
+ await trigger.locator.hover({ timeout: 3000 }).catch(() => {
6231
+ return;
6232
+ });
6233
+ await page.waitForTimeout(1500);
6234
+ const hoverOpened = await isCartRevealed(page, expectedProductTitle);
6235
+ if (hoverOpened) {
6236
+ dlog2(ctx, ` openMinicart: hover opened drawer (${hoverOpened})`);
6237
+ return { method: "hover", url: page.url(), visibleMarker: hoverOpened };
6238
+ }
6239
+ }
6240
+ if (hrefHasCartTarget && triggerHref) {
6241
+ const targetUrl = (() => {
6242
+ try {
6243
+ return new URL(triggerHref, page.url()).toString();
6244
+ } catch {
6245
+ return null;
6246
+ }
6247
+ })();
6248
+ if (targetUrl) {
6249
+ dlog2(ctx, ` openMinicart: all interactive strategies failed but trigger has cart href, navigating directly to ${targetUrl}`);
6250
+ await page.goto(targetUrl, { waitUntil: "load", timeout: 15000 }).catch(() => {
6251
+ return;
6252
+ });
6253
+ await waitForCartHydration(page);
6254
+ if (page.url() !== beforeUrl) {
6255
+ dlog2(ctx, ` openMinicart: goto fallback landed on ${page.url()}`);
6256
+ return { method: "click-navigate", url: page.url(), visibleMarker: null };
6257
+ }
6258
+ }
6259
+ }
6260
+ dlog2(ctx, " openMinicart: failed — no cart revealed by hover/click/goto");
6261
+ return { method: "failed", url: page.url(), visibleMarker: null };
6262
+ }
6263
+ function normalizeTitle(s) {
6264
+ return s.toLowerCase().replace(/[®©™]/g, "").replace(/[^\p{L}\p{N}\s]+/gu, " ").replace(/\s+/g, " ").trim();
6265
+ }
6266
+ function titlesMatch(observed, expected) {
6267
+ const o = normalizeTitle(observed);
6268
+ const e = normalizeTitle(expected);
6269
+ if (!o || !e)
6270
+ return false;
6271
+ if (o === e)
6272
+ return true;
6273
+ if (e.length >= 12 && o.includes(e))
6274
+ return true;
6275
+ if (o.length >= 12 && e.includes(o))
6276
+ return true;
6277
+ return false;
6278
+ }
6279
+ async function validateCartContainsTitle(page, expectedTitle, ctx) {
6280
+ const titleSelectors = [
6281
+ "[data-cart-item-name]",
6282
+ "[data-cart-item] [class*='title' i]",
6283
+ "[data-cart-item] [class*='name' i]",
6284
+ "[class*='cart' i] [data-product-name]",
6285
+ "[role='dialog'] [data-product-name]",
6286
+ "[class*='checkout' i] [data-product-name]",
6287
+ "[class*='minicart' i] [data-product-name]",
6288
+ "[data-testid='cart-item-name']",
6289
+ "[data-testid='product-name']",
6290
+ "[role='dialog'] li [class*='product' i]",
6291
+ "[role='dialog'] li [class*='name' i]",
6292
+ "[role='dialog'] li [class*='title' i]",
6293
+ "[role='dialog'] a[href*='/p']",
6294
+ "[class*='minicart' i] [class*='item' i] [class*='name' i]",
6295
+ "[class*='minicart' i] [class*='item' i] [class*='title' i]",
6296
+ "[class*='cart-item' i] [class*='name' i]",
6297
+ "[class*='cart-item' i] [class*='title' i]",
6298
+ "[class*='checkout' i] [class*='product' i] [class*='name' i]",
6299
+ ".vtex-minicart-2-x-itemNameContainer",
6300
+ ".vtex-checkout-summary-0-x-itemName",
6301
+ ".product-name",
6302
+ ".item-name",
6303
+ "a.product-name",
6304
+ ".cart-items .item-name",
6305
+ "tr.product-item .item-name",
6306
+ "tr.cart-item .item-name",
6307
+ "table.cart-items td a",
6308
+ "#cart-fixed .item .product-name",
6309
+ "#cart-fixed .item-name",
6310
+ ".cart-fixed .item-name",
6311
+ ".cart-fixed .product-name",
6312
+ "#cart-fixed li a",
6313
+ "#minicart-content .item-name",
6314
+ "[data-fs-cart-item-summary-title]",
6315
+ "[data-fs-cart-item-image] + * a",
6316
+ "[class*='cart' i] a[href*='/p']",
6317
+ "[class*='checkout' i] a[href*='/p']"
6318
+ ];
6319
+ const sweepTitles = async () => {
6320
+ const observed2 = [];
6321
+ for (const sel of titleSelectors) {
6322
+ try {
6323
+ const loc = page.locator(sel);
6324
+ const count = await withCap(loc.count(), 1000, 0);
6325
+ const limit = Math.min(count, 10);
6326
+ for (let i = 0;i < limit; i++) {
6327
+ const el = loc.nth(i);
6328
+ const visible = await withCap(el.isVisible({ timeout: 200 }).catch(() => false), 400, false);
6329
+ if (!visible)
6330
+ continue;
6331
+ const text = await withCap(el.innerText().catch(() => ""), 500, "");
6332
+ const clean = text.trim().slice(0, 200);
6333
+ if (clean.length > 2)
6334
+ observed2.push(clean);
6335
+ }
6336
+ if (observed2.length > 0)
6337
+ return observed2;
6338
+ } catch {}
6339
+ }
6340
+ return observed2;
6341
+ };
6342
+ let observed = await sweepTitles();
6343
+ if (observed.length === 0) {
6344
+ dlog2(ctx, " validateCartContainsTitle: 0 titles on first pass, retrying after 2s");
6345
+ await page.waitForTimeout(2000);
6346
+ observed = await sweepTitles();
6347
+ }
6348
+ dlog2(ctx, ` validateCartContainsTitle: observed ${observed.length} titles ${observed.length ? `[${observed.slice(0, 3).map((t) => t.slice(0, 30)).join(" | ")}${observed.length > 3 ? " | …" : ""}]` : ""}`);
6349
+ if (observed.length === 0) {
6350
+ return { found: false, observedTitles: [], method: "none" };
6351
+ }
6352
+ const found = observed.some((o) => titlesMatch(o, expectedTitle));
6353
+ return { found, observedTitles: observed, method: "selector" };
6354
+ }
6355
+ async function detectEmptyCartBanner(page) {
6356
+ const bannerSelectors = [
6357
+ ":text-matches('carrinho.*vazio', 'i')",
6358
+ ":text-matches('seu carrinho está vazio', 'i')",
6359
+ ":text-matches('empty cart', 'i')",
6360
+ ":text-matches('cart is empty', 'i')",
6361
+ ":text-matches('nenhum item.*carrinho', 'i')",
6362
+ "[class*='empty' i][class*='cart' i]",
6363
+ "[class*='cart-empty' i]"
6364
+ ];
6365
+ for (const sel of bannerSelectors) {
6366
+ try {
6367
+ const loc = page.locator(sel).first();
6368
+ if (await withCap(loc.isVisible({ timeout: 300 }).catch(() => false), 500, false)) {
6369
+ const text = await withCap(loc.innerText().catch(() => ""), 500, "");
6370
+ if (text.trim())
6371
+ return text.trim().slice(0, 120);
6372
+ }
6373
+ } catch {}
6374
+ }
6375
+ return null;
6376
+ }
5683
6377
  async function attemptRecovery(page, _ctx, stepName, intendedAction, alreadyTried) {
5684
6378
  let html = "";
5685
6379
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/parity",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",