@decocms/parity 0.2.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 +57 -0
  2. package/dist/cli.js +843 -69
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,63 @@ 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
+
35
+ ## [0.3.0](https://github.com/decocms/parity/compare/v0.2.0...v0.3.0) (2026-05-26)
36
+
37
+
38
+ ### Added
39
+
40
+ * **journey:** retry `go-checkout` via LLM recovery when the default selector clicks the wrong element and the URL never reaches `/checkout` ([5826d30](https://github.com/decocms/parity/commit/5826d309c6a910f8cf8017667cdcdcebd65f65d9))
41
+ * **journey:** LLM recovery on `cep-pdp` + `cep-cart` when defaults miss the CEP input ([624b0f5](https://github.com/decocms/parity/commit/624b0f5acbe1c50fce2bea69faa6906e29d36e64))
42
+ * **journey:** per-flow hard deadline so a single hung flow can't freeze the whole crawl ([e2d4a68](https://github.com/decocms/parity/commit/e2d4a681a5d4f0a9627251eceb1e733d12bdc68d))
43
+
44
+
45
+ ### Fixed
46
+
47
+ * **journey:** abort in-flight Playwright ops when the deadline fires, instead of letting them mutate the next flow's shared BrowserContext ([a21aa78](https://github.com/decocms/parity/commit/a21aa78a4431e7a2cff9aac26ac34c4ca2fa768c))
48
+ * **journey:** seal the timeout FlowCapture synchronously so Promise.race can't pick up the inner rejection caused by closing pages ([d2a0f1e](https://github.com/decocms/parity/commit/d2a0f1e7a08bf95a28a951bace322c5baf062bea))
49
+ * **journey:** await timeout cleanup before runFlow returns so the next flow on the same context isn't racing in-flight close()s ([a2fd6c2](https://github.com/decocms/parity/commit/a2fd6c2e7e7a9eb493d2cb75d08f3d5cc7cc3f91))
50
+
51
+ ## [0.2.0](https://github.com/decocms/parity/compare/v0.1.1...v0.2.0) (2026-05-26)
52
+
53
+
54
+ ### Added
55
+
56
+ * **css-trace:** inspect CSS rules affecting a DOM element ([1e323fa](https://github.com/decocms/parity/commit/1e323fa17564045d5f55bfbc5cbf2fcdb0112877))
57
+
58
+
59
+ ### Fixed
60
+
61
+ * **capture:** hard outer deadline so capturePage cannot exceed budget+10s ([966b9a5](https://github.com/decocms/parity/commit/966b9a59851536262b88510da6175a215c90d0b2))
62
+ * **capture:** hard outer deadline so capturePage cannot exceed budget+10s ([ea61116](https://github.com/decocms/parity/commit/ea61116c81fb1ec86e86ddb24216214380a81c81))
63
+ * **lint:** replace 3 template literals without interpolation with strings ([c60f19c](https://github.com/decocms/parity/commit/c60f19c4a56c8441ead829fca0998ee36a671872))
64
+
8
65
  ## [Unreleased]
9
66
 
10
67
  ## [0.1.1] — 2026-05-22
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,22 +5183,79 @@ 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
  }
5198
+ var FLOW_DEADLINE_MS = {
5199
+ homepage: 90000,
5200
+ plp: 180000,
5201
+ pdp: 240000,
5202
+ "purchase-journey": 360000
5203
+ };
5062
5204
  async function runFlow(flow, ctx) {
5063
5205
  const start = Date.now();
5064
- switch (flow) {
5065
- case "homepage":
5066
- return finalize(flow, ctx, await flowHomepage(ctx), [], start);
5067
- case "plp":
5068
- return finalize(flow, ctx, await flowPlp(ctx), [], start);
5069
- case "pdp":
5070
- return finalize(flow, ctx, await flowPdp(ctx), [], start);
5071
- case "purchase-journey": {
5072
- const { pages, steps } = await flowPurchaseJourney(ctx);
5073
- return finalize(flow, ctx, pages, steps, start);
5206
+ const deadlineMs = FLOW_DEADLINE_MS[flow];
5207
+ const inner = async () => {
5208
+ switch (flow) {
5209
+ case "homepage":
5210
+ return finalize(flow, ctx, await flowHomepage(ctx), [], start);
5211
+ case "plp":
5212
+ return finalize(flow, ctx, await flowPlp(ctx), [], start);
5213
+ case "pdp":
5214
+ return finalize(flow, ctx, await flowPdp(ctx), [], start);
5215
+ case "purchase-journey": {
5216
+ const { pages, steps } = await flowPurchaseJourney(ctx);
5217
+ return finalize(flow, ctx, pages, steps, start);
5218
+ }
5074
5219
  }
5220
+ };
5221
+ const innerPromise = inner();
5222
+ innerPromise.catch(() => {
5223
+ return;
5224
+ });
5225
+ let timer;
5226
+ let cleanup = Promise.resolve();
5227
+ const timeoutPromise = new Promise((resolve) => {
5228
+ timer = setTimeout(() => {
5229
+ const pages = ctx.ctx.pages();
5230
+ resolve(finalize(flow, ctx, [], [
5231
+ {
5232
+ step: 0,
5233
+ name: "flow-timeout",
5234
+ side: ctx.side,
5235
+ viewport: ctx.viewport,
5236
+ status: "failed",
5237
+ durationMs: deadlineMs,
5238
+ screenshotPath: "",
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.`
5240
+ }
5241
+ ], start));
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));
5250
+ }, deadlineMs);
5251
+ });
5252
+ try {
5253
+ const result = await Promise.race([innerPromise, timeoutPromise]);
5254
+ await cleanup;
5255
+ return result;
5256
+ } finally {
5257
+ if (timer !== undefined)
5258
+ clearTimeout(timer);
5075
5259
  }
5076
5260
  }
5077
5261
  function finalize(flow, ctx, pages, steps, start) {
@@ -5178,12 +5362,15 @@ async function flowPurchaseJourney(ctx) {
5178
5362
  };
5179
5363
  try {
5180
5364
  reportStart(1, "visit-home");
5365
+ dlog2(ctx, `step 1 visit-home: capturePage(${ctx.baseUrl}) start (scrollToLoad=false)`);
5181
5366
  const homeCap = await capturePage(page, {
5182
5367
  url: ctx.baseUrl,
5183
5368
  side: ctx.side,
5184
5369
  viewport: ctx.viewport,
5185
- screenshotPath: screenshotPath(ctx, "pj-1-home")
5370
+ screenshotPath: screenshotPath(ctx, "pj-1-home"),
5371
+ scrollToLoad: false
5186
5372
  });
5373
+ dlog2(ctx, `step 1 visit-home: capturePage done status=${homeCap.status}`);
5187
5374
  pages.push(homeCap);
5188
5375
  const step1Status = homeCap.status >= 200 && homeCap.status < 400 ? "ok" : "failed";
5189
5376
  steps.push({
@@ -5202,19 +5389,24 @@ async function flowPurchaseJourney(ctx) {
5202
5389
  return { pages, steps };
5203
5390
  }
5204
5391
  reportStart(2, "navigate-plp");
5392
+ dlog2(ctx, `step 2 navigate-plp: findCategoryUrl start (hint=${ctx.rc.plpUrlHint ?? "—"})`);
5205
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"}`);
5206
5395
  if (!plpHit) {
5207
5396
  steps.push(makeSkipStep(2, "navigate-plp", ctx, "no category link found"));
5208
5397
  reportEnd(2, "navigate-plp", "skipped", 0, "no category link found");
5209
5398
  return { pages, steps };
5210
5399
  }
5211
5400
  const t2 = Date.now();
5401
+ dlog2(ctx, `step 2 navigate-plp: capturePage(${plpHit.url}) start (scrollToLoad=false)`);
5212
5402
  const plpCap = await capturePage(page, {
5213
5403
  url: plpHit.url,
5214
5404
  side: ctx.side,
5215
5405
  viewport: ctx.viewport,
5216
- screenshotPath: screenshotPath(ctx, "pj-2-plp")
5406
+ screenshotPath: screenshotPath(ctx, "pj-2-plp"),
5407
+ scrollToLoad: false
5217
5408
  });
5409
+ dlog2(ctx, `step 2 navigate-plp: capturePage done status=${plpCap.status}`);
5218
5410
  pages.push(plpCap);
5219
5411
  const step2Status = plpCap.status >= 200 && plpCap.status < 400 ? "ok" : "failed";
5220
5412
  steps.push({
@@ -5244,7 +5436,8 @@ async function flowPurchaseJourney(ctx) {
5244
5436
  url: pdpHit.url,
5245
5437
  side: ctx.side,
5246
5438
  viewport: ctx.viewport,
5247
- screenshotPath: screenshotPath(ctx, "pj-3-pdp")
5439
+ screenshotPath: screenshotPath(ctx, "pj-3-pdp"),
5440
+ scrollToLoad: false
5248
5441
  });
5249
5442
  pages.push(pdpCap);
5250
5443
  const step3Status = pdpCap.status >= 200 && pdpCap.status < 400 ? "ok" : "failed";
@@ -5263,8 +5456,25 @@ async function flowPurchaseJourney(ctx) {
5263
5456
  reportEnd(3, "enter-pdp", step3Status, Date.now() - t3);
5264
5457
  steps[steps.length - 1].actionDescription = `Abriu PDP \`${pdpHit.url}\` (via \`${pdpHit.selector}\`)`;
5265
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
+ }
5266
5467
  reportStart(4, "shipping-calc-pdp");
5267
- const cepInputPdp = await firstVisible(page, selFor(ctx, "cepInputPdp"));
5468
+ let cepInputPdp = await firstVisible(page, selFor(ctx, "cepInputPdp"));
5469
+ let cepPdpRecovered = false;
5470
+ if (!cepInputPdp && recoveryBudget > 0) {
5471
+ const recovery = await attemptRecovery(page, ctx, "shipping-calc-pdp", "Achar o input de CEP / código postal nesta PDP (deve ser um input visível com label/placeholder relacionado a frete, entrega ou CEP)", selFor(ctx, "cepInputPdp"));
5472
+ if (recovery) {
5473
+ cepInputPdp = recovery.selector;
5474
+ cepPdpRecovered = true;
5475
+ recoveryBudget--;
5476
+ }
5477
+ }
5268
5478
  if (cepInputPdp) {
5269
5479
  const t4 = Date.now();
5270
5480
  const beforeUrl4 = page.url();
@@ -5289,14 +5499,15 @@ async function flowPurchaseJourney(ctx) {
5289
5499
  screenshotPath: sp,
5290
5500
  screenshotBeforePath: spBefore4,
5291
5501
  beforeUrl: beforeUrl4,
5292
- actionDescription: `Preencheu CEP '${ctx.rc.cep}' no input \`${cepInputPdp}\` e disparou cálculo de frete`,
5502
+ actionDescription: `Preencheu CEP '${ctx.rc.cep}' no input \`${cepInputPdp}\` e disparou cálculo de frete${cepPdpRecovered ? " (selector via LLM recovery)" : ""}`,
5293
5503
  detail: { cepUsed: ctx.rc.cep },
5294
5504
  selectorKey: "cepInputPdp",
5295
- usedSelector: cepInputPdp
5505
+ usedSelector: cepInputPdp,
5506
+ recoveredByLlm: cepPdpRecovered || undefined
5296
5507
  });
5297
5508
  reportEnd(4, "shipping-calc-pdp", step4Status, Date.now() - t4);
5298
5509
  } else {
5299
- steps.push(makeSkipStep(4, "shipping-calc-pdp", ctx, "no CEP input on PDP"));
5510
+ steps.push(makeSkipStep(4, "shipping-calc-pdp", ctx, "no CEP input on PDP (recovery exhausted)"));
5300
5511
  reportEnd(4, "shipping-calc-pdp", "skipped", 0, "no CEP input on PDP");
5301
5512
  }
5302
5513
  reportStart(5, "add-to-cart");
@@ -5364,37 +5575,79 @@ async function flowPurchaseJourney(ctx) {
5364
5575
  recoveryBudget--;
5365
5576
  }
5366
5577
  }
5578
+ let cartOpenMethod = "failed";
5367
5579
  let miniText = "";
5368
5580
  if (miniHit) {
5369
5581
  miniText = await miniHit.locator.innerText().catch(() => "");
5370
- await miniHit.locator.click({ timeout: 3000 }).catch(() => {
5371
- return;
5372
- });
5373
- 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");
5374
5615
  }
5375
5616
  const sp6 = screenshotPath(ctx, "pj-6-minicart");
5376
5617
  await page.screenshot({ path: sp6, fullPage: false }).catch(() => {
5377
5618
  return;
5378
5619
  });
5620
+ const step6Status = cartOpenMethod === "failed" ? "failed" : step6Validation && !step6Validation.found ? "failed" : "ok";
5379
5621
  steps.push({
5380
5622
  step: 6,
5381
5623
  name: "open-minicart",
5382
5624
  side: ctx.side,
5383
5625
  viewport: ctx.viewport,
5384
- status: "ok",
5626
+ status: step6Status,
5385
5627
  durationMs: Date.now() - t6,
5386
5628
  url: page.url(),
5387
5629
  screenshotPath: sp6,
5388
5630
  screenshotBeforePath: spBefore6,
5389
5631
  beforeUrl: beforeUrl6,
5390
- 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",
5391
5635
  selectorKey: miniHit ? "minicartTrigger" : undefined,
5392
5636
  usedSelector: miniHit?.selector,
5393
5637
  recoveredByLlm: miniRecovered || undefined
5394
5638
  });
5395
- reportEnd(6, "open-minicart", "ok", Date.now() - t6);
5639
+ reportEnd(6, "open-minicart", step6Status, Date.now() - t6);
5396
5640
  reportStart(7, "shipping-calc-cart");
5397
- const cepInputCart = await firstVisible(page, selFor(ctx, "cepInputCart"));
5641
+ let cepInputCart = await firstVisible(page, selFor(ctx, "cepInputCart"));
5642
+ let cepCartRecovered = false;
5643
+ if (!cepInputCart && recoveryBudget > 0) {
5644
+ const recovery = await attemptRecovery(page, ctx, "shipping-calc-cart", "Achar o input de CEP / código postal dentro do carrinho ou minicart aberto agora (input visível com label/placeholder de frete, entrega ou CEP)", selFor(ctx, "cepInputCart"));
5645
+ if (recovery) {
5646
+ cepInputCart = recovery.selector;
5647
+ cepCartRecovered = true;
5648
+ recoveryBudget--;
5649
+ }
5650
+ }
5398
5651
  if (cepInputCart) {
5399
5652
  const t7 = Date.now();
5400
5653
  const beforeUrl7 = page.url();
@@ -5419,21 +5672,83 @@ async function flowPurchaseJourney(ctx) {
5419
5672
  screenshotPath: sp7,
5420
5673
  screenshotBeforePath: spBefore7,
5421
5674
  beforeUrl: beforeUrl7,
5422
- actionDescription: `Preencheu CEP '${ctx.rc.cep}' no carrinho (\`${cepInputCart}\`)`,
5675
+ actionDescription: `Preencheu CEP '${ctx.rc.cep}' no carrinho (\`${cepInputCart}\`)${cepCartRecovered ? " (selector via LLM recovery)" : ""}`,
5423
5676
  detail: { cepUsed: ctx.rc.cep },
5424
5677
  selectorKey: "cepInputCart",
5425
- usedSelector: cepInputCart
5678
+ usedSelector: cepInputCart,
5679
+ recoveredByLlm: cepCartRecovered || undefined
5426
5680
  });
5427
5681
  reportEnd(7, "shipping-calc-cart", step7Status, Date.now() - t7);
5428
5682
  } else {
5429
- steps.push(makeSkipStep(7, "shipping-calc-cart", ctx, "no CEP input in cart"));
5683
+ steps.push(makeSkipStep(7, "shipping-calc-cart", ctx, "no CEP input in cart (recovery exhausted)"));
5430
5684
  reportEnd(7, "shipping-calc-cart", "skipped", 0, "no CEP input in cart");
5431
5685
  }
5432
5686
  reportStart(8, "go-checkout");
5433
- 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"}`);
5434
5746
  let checkoutRecovered = false;
5435
5747
  if (!checkoutHit && recoveryBudget > 0) {
5436
- 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"}`);
5437
5752
  if (recovery) {
5438
5753
  checkoutHit = recovery;
5439
5754
  checkoutRecovered = true;
@@ -5441,33 +5756,92 @@ async function flowPurchaseJourney(ctx) {
5441
5756
  }
5442
5757
  }
5443
5758
  if (!checkoutHit) {
5759
+ dlog2(ctx, "step 8 go-checkout: no button found, skipping");
5444
5760
  steps.push(makeSkipStep(8, "go-checkout", ctx, "no checkout button found (recovery exhausted)"));
5445
5761
  reportEnd(8, "go-checkout", "skipped", 0, "no checkout button found");
5446
5762
  return { pages, steps };
5447
5763
  }
5448
- const t8 = Date.now();
5449
- const beforeUrl8 = page.url();
5450
- const spBefore8 = screenshotPath(ctx, "pj-8-checkout-before");
5451
- await page.screenshot({ path: spBefore8, fullPage: false }).catch(() => {
5452
- return;
5453
- });
5454
- const checkoutText = await checkoutHit.locator.innerText().catch(() => "");
5455
- await Promise.all([
5456
- page.waitForURL(/checkout/i, { timeout: 1e4 }).catch(() => {
5764
+ const tryCheckoutClick = async (hit, attempt2) => {
5765
+ const spBefore = screenshotPath(ctx, `pj-8-checkout-before-${attempt2}`);
5766
+ await page.screenshot({ path: spBefore, fullPage: false }).catch(() => {
5457
5767
  return;
5458
- }),
5459
- checkoutHit.locator.click({ timeout: 5000 }).catch(() => {
5768
+ });
5769
+ const clickedText2 = await hit.locator.innerText().catch(() => "");
5770
+ const urlBefore = page.url();
5771
+ const urlChangePredicate = alreadyInCheckoutPage ? (u) => u.toString() !== urlBefore : /checkout/i;
5772
+ await Promise.all([
5773
+ page.waitForURL(urlChangePredicate, { timeout: 1e4 }).catch(() => {
5774
+ return;
5775
+ }),
5776
+ hit.locator.click({ timeout: 5000 }).catch(() => {
5777
+ return;
5778
+ })
5779
+ ]);
5780
+ await page.waitForTimeout(1500);
5781
+ const spAfter = screenshotPath(ctx, `pj-8-checkout-reached-${attempt2}`);
5782
+ await page.screenshot({ path: spAfter, fullPage: false }).catch(() => {
5460
5783
  return;
5461
- })
5462
- ]);
5463
- await page.waitForTimeout(1500);
5464
- const sp8 = screenshotPath(ctx, "pj-8-checkout-reached");
5465
- await page.screenshot({ path: sp8, fullPage: false }).catch(() => {
5466
- return;
5467
- });
5468
- const checkoutUrl = page.url();
5469
- const reachedCheckout = /\/checkout/i.test(checkoutUrl);
5470
- const step8Status = reachedCheckout ? "ok" : "failed";
5784
+ });
5785
+ return { url: page.url(), spBefore, spAfter, clickedText: clickedText2 };
5786
+ };
5787
+ const t8 = Date.now();
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
+ };
5795
+ let attempt = 1;
5796
+ dlog2(ctx, `step 8 go-checkout: tryCheckoutClick attempt 1 — selector=${checkoutHit.selector}, beforeUrl=${beforeUrl8}`);
5797
+ let result = await tryCheckoutClick(checkoutHit, attempt);
5798
+ let reachedCheckout = isReachedCheckout(result.url);
5799
+ let usedSelector = checkoutHit.selector;
5800
+ let clickedText = result.clickedText;
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"}`);
5807
+ if (retrySuggestion) {
5808
+ recoveryBudget--;
5809
+ checkoutRecovered = true;
5810
+ attempt++;
5811
+ dlog2(ctx, `step 8 go-checkout: tryCheckoutClick attempt 2 — selector=${retrySuggestion.selector}`);
5812
+ const retryResult = await tryCheckoutClick(retrySuggestion, attempt);
5813
+ result = retryResult;
5814
+ usedSelector = retrySuggestion.selector;
5815
+ clickedText = retryResult.clickedText;
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()}'`);
5818
+ }
5819
+ } else if (!reachedCheckout) {
5820
+ dlog2(ctx, `step 8 go-checkout: not retrying — recoveryBudget=${recoveryBudget}`);
5821
+ }
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";
5471
5845
  steps.push({
5472
5846
  step: 8,
5473
5847
  name: "go-checkout",
@@ -5475,13 +5849,14 @@ async function flowPurchaseJourney(ctx) {
5475
5849
  viewport: ctx.viewport,
5476
5850
  status: step8Status,
5477
5851
  durationMs: Date.now() - t8,
5478
- url: checkoutUrl,
5479
- screenshotPath: sp8,
5480
- screenshotBeforePath: spBefore8,
5852
+ url: result.url,
5853
+ screenshotPath: result.spAfter,
5854
+ screenshotBeforePath: result.spBefore,
5481
5855
  beforeUrl: beforeUrl8,
5482
- actionDescription: `Clicou em${checkoutText ? ` '${checkoutText.slice(0, 30).trim()}'` : ""} (\`${checkoutHit.selector}\`); URL final: ${checkoutUrl}${reachedCheckout ? " ✓ atingiu /checkout" : " ✗ não foi pra checkout"}`,
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)` : ""}`,
5483
5858
  selectorKey: "checkoutButton",
5484
- usedSelector: checkoutHit.selector,
5859
+ usedSelector,
5485
5860
  recoveredByLlm: checkoutRecovered || undefined
5486
5861
  });
5487
5862
  reportEnd(8, "go-checkout", step8Status, Date.now() - t8);
@@ -5504,10 +5879,15 @@ function makeSkipStep(step, name, ctx, note) {
5504
5879
  }
5505
5880
  async function findCategoryUrl(page, ctx) {
5506
5881
  const selectors = selFor(ctx, "categoryLink");
5882
+ dlog2(ctx, ` findCategoryUrl: collectCandidateLinks(${selectors.length} selectors) start`);
5883
+ const t0 = Date.now();
5507
5884
  const candidates = await collectCandidateLinks(page, selectors, 12);
5885
+ dlog2(ctx, ` findCategoryUrl: collectCandidateLinks done in ${Date.now() - t0}ms → ${candidates.length} candidates`);
5508
5886
  if (candidates.length === 0)
5509
5887
  return null;
5888
+ dlog2(ctx, " findCategoryUrl: pickCategoryLink LLM call start");
5510
5889
  const picked = await pickCategoryLink(candidates.map((c) => ({ text: c.text, href: c.href })));
5890
+ dlog2(ctx, ` findCategoryUrl: pickCategoryLink LLM done → ${picked?.href ?? "null"}`);
5511
5891
  if (!picked)
5512
5892
  return null;
5513
5893
  const original = candidates.find((c) => c.href === picked.href);
@@ -5556,20 +5936,34 @@ async function firstVisibleLocator(page, selectors) {
5556
5936
  }
5557
5937
  return null;
5558
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
+ }
5559
5946
  async function collectCandidateLinks(page, selectors, limit = 12) {
5560
5947
  const out = [];
5561
5948
  const seenHrefs = new Set;
5949
+ const deadline = Date.now() + COLLECT_CANDIDATES_BUDGET_MS;
5950
+ const expired = () => Date.now() >= deadline;
5562
5951
  for (const sel of selectors) {
5563
5952
  if (out.length >= limit)
5564
5953
  break;
5954
+ if (expired())
5955
+ break;
5565
5956
  try {
5566
5957
  const elements = page.locator(sel);
5567
- const count = await elements.count();
5958
+ const count = await withCap(elements.count(), 1000, 0);
5568
5959
  for (let i = 0;i < count && out.length < limit; i++) {
5960
+ if (expired())
5961
+ break;
5569
5962
  const el = elements.nth(i);
5570
- if (!await el.isVisible({ timeout: 250 }).catch(() => false))
5963
+ const visible = await withCap(el.isVisible({ timeout: 250 }).catch(() => false), 400, false);
5964
+ if (!visible)
5571
5965
  continue;
5572
- const href = await el.getAttribute("href").catch(() => null);
5966
+ const href = await withCap(el.getAttribute("href").catch(() => null), 400, null);
5573
5967
  if (!href)
5574
5968
  continue;
5575
5969
  let abs = href;
@@ -5581,7 +5975,7 @@ async function collectCandidateLinks(page, selectors, limit = 12) {
5581
5975
  if (seenHrefs.has(abs))
5582
5976
  continue;
5583
5977
  seenHrefs.add(abs);
5584
- const text = (await el.innerText().catch(() => "")).slice(0, 60).trim();
5978
+ const text = (await withCap(el.innerText().catch(() => ""), 400, "")).slice(0, 60).trim();
5585
5979
  out.push({ text, href: abs, selector: sel });
5586
5980
  }
5587
5981
  } catch {}
@@ -5600,6 +5994,386 @@ async function fillCep(page, selector, cep) {
5600
5994
  return false;
5601
5995
  }
5602
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
+ }
5603
6377
  async function attemptRecovery(page, _ctx, stepName, intendedAction, alreadyTried) {
5604
6378
  let html = "";
5605
6379
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/parity",
3
- "version": "0.2.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",