@decocms/parity 0.3.0 → 0.5.4

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 +52 -0
  2. package/dist/cli.js +1517 -211
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,21 +1,5 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
- var __create = Object.create;
4
- var __getProtoOf = Object.getPrototypeOf;
5
- var __defProp = Object.defineProperty;
6
- var __getOwnPropNames = Object.getOwnPropertyNames;
7
- var __hasOwnProp = Object.prototype.hasOwnProperty;
8
- var __toESM = (mod, isNodeMode, target) => {
9
- target = mod != null ? __create(__getProtoOf(mod)) : {};
10
- const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
11
- for (let key of __getOwnPropNames(mod))
12
- if (!__hasOwnProp.call(to, key))
13
- __defProp(to, key, {
14
- get: () => mod[key],
15
- enumerable: true
16
- });
17
- return to;
18
- };
19
3
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
20
4
 
21
5
  // src/cli.ts
@@ -99,7 +83,15 @@ var StepCapture = z.object({
99
83
  actionDescription: z.string().optional(),
100
84
  beforeUrl: z.string().optional(),
101
85
  screenshotBeforePath: z.string().optional(),
102
- tracePath: z.string().optional()
86
+ tracePath: z.string().optional(),
87
+ cartValidation: z.object({
88
+ expectedTitle: z.string(),
89
+ found: z.boolean(),
90
+ method: z.enum(["selector", "llm", "none"]).optional(),
91
+ observedTitles: z.array(z.string()).optional(),
92
+ reason: z.string().optional()
93
+ }).optional(),
94
+ cartOpenMethod: z.enum(["click", "click-navigate", "hover", "already-open", "failed"]).optional()
103
95
  });
104
96
  var FlowCapture = z.object({
105
97
  flow: FlowName,
@@ -294,7 +286,13 @@ var ParityRc = z.object({
294
286
  minicartTrigger: z.string().optional(),
295
287
  cepInputPdp: z.string().optional(),
296
288
  cepInputCart: z.string().optional(),
297
- checkoutButton: z.string().optional()
289
+ checkoutButton: z.string().optional(),
290
+ sizeSwatch: z.string().optional(),
291
+ colorSwatch: z.string().optional(),
292
+ variantRow: z.string().optional(),
293
+ quantityIncrement: z.string().optional(),
294
+ quantityInput: z.string().optional(),
295
+ minicartCount: z.string().optional()
298
296
  }).default({}),
299
297
  skipSteps: z.array(z.string()).default([])
300
298
  });
@@ -931,6 +929,15 @@ async function stopTracing(ctx, path) {
931
929
  }
932
930
 
933
931
  // src/engine/collect.ts
932
+ var DEBUG_PARITY = process.env.DEBUG_PARITY === "1" || process.env.DEBUG_PARITY === "true";
933
+ var DEBUG_START = Date.now();
934
+ function dlog(side, viewport, msg) {
935
+ if (!DEBUG_PARITY)
936
+ return;
937
+ const elapsed = ((Date.now() - DEBUG_START) / 1000).toFixed(1);
938
+ process.stderr.write(`[+${elapsed}s ${viewport}/${side}] ${msg}
939
+ `);
940
+ }
934
941
  var VITALS_INIT_SCRIPT = `
935
942
  (function() {
936
943
  if (window.__parity_vitals_installed) return;
@@ -1141,10 +1148,12 @@ async function capturePage(page, opts) {
1141
1148
  let html = "";
1142
1149
  const inner = async () => {
1143
1150
  try {
1151
+ dlog(opts.side, opts.viewport, ` capturePage: goto(${opts.url}) start`);
1144
1152
  response = await page.goto(opts.url, {
1145
1153
  waitUntil: "domcontentloaded",
1146
1154
  timeout: Math.min(opts.timeoutMs ?? 30000, remaining())
1147
1155
  });
1156
+ dlog(opts.side, opts.viewport, ` capturePage: goto done status=${response?.status() ?? "?"} (remaining=${remaining()}ms)`);
1148
1157
  finalUrl = page.url();
1149
1158
  if (response) {
1150
1159
  const headers = response.headers();
@@ -1156,20 +1165,25 @@ async function capturePage(page, opts) {
1156
1165
  });
1157
1166
  await page.waitForTimeout(Math.min(opts.settleMs ?? 1200, remaining()));
1158
1167
  } else {
1168
+ dlog(opts.side, opts.viewport, ` capturePage: waitForLoadState('load') (cap=${Math.min(12000, remaining())}ms)`);
1159
1169
  await page.waitForLoadState("load", { timeout: Math.min(12000, remaining()) }).catch(() => {
1160
1170
  return;
1161
1171
  });
1172
+ dlog(opts.side, opts.viewport, ` capturePage: waitForLoadState('networkidle') (cap=${Math.min(6000, remaining())}ms)`);
1162
1173
  await page.waitForLoadState("networkidle", { timeout: Math.min(6000, remaining()) }).catch(() => {
1163
1174
  return;
1164
1175
  });
1176
+ dlog(opts.side, opts.viewport, ` capturePage: settle (cap=${Math.min(opts.settleMs ?? 2000, remaining())}ms)`);
1165
1177
  await page.waitForTimeout(Math.min(opts.settleMs ?? 2000, remaining()));
1166
1178
  if (opts.scrollToLoad !== false && remaining() > 3000) {
1179
+ dlog(opts.side, opts.viewport, ` capturePage: scrollFullPage start (remaining=${remaining()}ms)`);
1167
1180
  await Promise.race([
1168
1181
  scrollFullPage(page).catch(() => {
1169
1182
  return;
1170
1183
  }),
1171
1184
  new Promise((resolve) => setTimeout(resolve, Math.min(1e4, remaining())))
1172
1185
  ]);
1186
+ dlog(opts.side, opts.viewport, ` capturePage: scrollFullPage done (remaining=${remaining()}ms)`);
1173
1187
  await page.waitForTimeout(Math.min(600, remaining()));
1174
1188
  }
1175
1189
  }
@@ -1179,23 +1193,29 @@ async function capturePage(page, opts) {
1179
1193
  text: `[navigation-error] ${err.message}`
1180
1194
  });
1181
1195
  }
1196
+ dlog(opts.side, opts.viewport, ` capturePage: vitals evaluate (cap=${Math.min(5000, remaining())}ms)`);
1182
1197
  vitals = await Promise.race([
1183
1198
  page.evaluate(() => window.__parity_vitals).catch(() => null),
1184
1199
  new Promise((resolve) => setTimeout(() => resolve(null), Math.min(5000, remaining())))
1185
1200
  ]) ?? null;
1186
1201
  if (!opts.skipScreenshot) {
1202
+ dlog(opts.side, opts.viewport, ` capturePage: screenshot start (cap=${Math.min(15000, remaining())}ms)`);
1187
1203
  await Promise.race([
1188
1204
  page.screenshot({ path: opts.screenshotPath, fullPage: true, animations: "disabled" }).catch(() => {
1189
1205
  return;
1190
1206
  }),
1191
1207
  new Promise((resolve) => setTimeout(resolve, Math.min(15000, remaining())))
1192
1208
  ]);
1209
+ dlog(opts.side, opts.viewport, " capturePage: screenshot done");
1193
1210
  }
1211
+ dlog(opts.side, opts.viewport, ` capturePage: page.content() (cap=${Math.min(5000, remaining())}ms)`);
1194
1212
  html = await Promise.race([
1195
1213
  page.content().catch(() => ""),
1196
1214
  new Promise((resolve) => setTimeout(() => resolve(""), Math.min(5000, remaining())))
1197
1215
  ]);
1216
+ dlog(opts.side, opts.viewport, ` capturePage: flushCollectors (cap=${Math.min(3000, remaining())}ms)`);
1198
1217
  await flushCollectors(state, Math.min(3000, remaining()));
1218
+ dlog(opts.side, opts.viewport, ` capturePage: inner done total=${Date.now() - start}ms`);
1199
1219
  return buildPartial();
1200
1220
  };
1201
1221
  const SAFETY_MARGIN_MS = 1e4;
@@ -4439,6 +4459,23 @@ async function callAnthropicTool(params) {
4439
4459
  return null;
4440
4460
  }
4441
4461
  async function callOpenRouterTool(params) {
4462
+ const baseTokens = params.maxTokens ?? 2000;
4463
+ const totalBudget = params.timeoutMs ?? defaultTimeout(params);
4464
+ const start = Date.now();
4465
+ for (let attempt = 1;attempt <= 2; attempt++) {
4466
+ const isRetry = attempt > 1;
4467
+ const remaining = totalBudget - (Date.now() - start);
4468
+ if (remaining <= 2000 && isRetry) {
4469
+ return null;
4470
+ }
4471
+ const tokens = isRetry ? Math.max(baseTokens * 2, 4000) : baseTokens;
4472
+ const result = await openRouterToolOnce(params, tokens, isRetry, Math.max(remaining, 2000));
4473
+ if (result !== undefined)
4474
+ return result;
4475
+ }
4476
+ return null;
4477
+ }
4478
+ async function openRouterToolOnce(params, maxTokens, isRetry, attemptTimeoutMs) {
4442
4479
  const apiKey = process.env.OPENROUTER_API_KEY;
4443
4480
  if (!apiKey)
4444
4481
  return null;
@@ -4451,8 +4488,7 @@ async function callOpenRouterTool(params) {
4451
4488
  }
4452
4489
  });
4453
4490
  }
4454
- const timeoutMs = params.timeoutMs ?? defaultTimeout(params);
4455
- const { signal, clear } = makeTimeoutSignal(timeoutMs);
4491
+ const { signal, clear } = makeTimeoutSignal(attemptTimeoutMs);
4456
4492
  try {
4457
4493
  const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
4458
4494
  method: "POST",
@@ -4465,7 +4501,7 @@ async function callOpenRouterTool(params) {
4465
4501
  },
4466
4502
  body: JSON.stringify({
4467
4503
  model: LLM_MODEL_OPENROUTER,
4468
- max_tokens: params.maxTokens ?? 2000,
4504
+ max_tokens: maxTokens,
4469
4505
  messages: [
4470
4506
  { role: "system", content: params.systemPrompt },
4471
4507
  { role: "user", content: userContent }
@@ -4486,16 +4522,29 @@ async function callOpenRouterTool(params) {
4486
4522
  if (!response.ok) {
4487
4523
  const txt = await response.text().catch(() => "");
4488
4524
  console.error(`[llm-openrouter] HTTP ${response.status}: ${txt.slice(0, 200)}`);
4525
+ if (response.status >= 500 || response.status === 429)
4526
+ return;
4489
4527
  return null;
4490
4528
  }
4491
4529
  const json = await response.json();
4492
4530
  const toolCall = json.choices?.[0]?.message?.tool_calls?.[0];
4493
4531
  if (toolCall?.function?.name === params.tool.name) {
4532
+ const raw = toolCall.function.arguments ?? "";
4494
4533
  try {
4495
- return JSON.parse(toolCall.function.arguments);
4496
- } catch (err) {
4497
- console.error(`[llm-openrouter] failed to parse tool arguments: ${err.message}`);
4498
- return null;
4534
+ return JSON.parse(raw);
4535
+ } catch {
4536
+ const repaired = tryRepairJson(raw);
4537
+ if (repaired) {
4538
+ try {
4539
+ return JSON.parse(repaired);
4540
+ } catch {}
4541
+ }
4542
+ if (isRetry) {
4543
+ console.error(`[llm-openrouter] failed to parse tool arguments after retry (raw len=${raw.length}, head=${JSON.stringify(raw.slice(0, 80))})`);
4544
+ return null;
4545
+ }
4546
+ console.error(`[llm-openrouter] parse error, retrying with more tokens (raw len=${raw.length}, head=${JSON.stringify(raw.slice(0, 80))})`);
4547
+ return;
4499
4548
  }
4500
4549
  }
4501
4550
  const content = json.choices?.[0]?.message?.content;
@@ -4504,12 +4553,67 @@ async function callOpenRouterTool(params) {
4504
4553
  return JSON.parse(content);
4505
4554
  } catch {}
4506
4555
  }
4556
+ return null;
4507
4557
  } catch (err) {
4508
- console.error(`[llm-openrouter] failed: ${err.message}`);
4558
+ const msg = err.message;
4559
+ console.error(`[llm-openrouter] failed: ${msg}`);
4560
+ if (!isRetry && (msg.includes("ECONN") || msg.includes("aborted") || msg.includes("fetch"))) {
4561
+ return;
4562
+ }
4563
+ return null;
4509
4564
  } finally {
4510
4565
  clear();
4511
4566
  }
4512
- return null;
4567
+ }
4568
+ function tryRepairJson(raw) {
4569
+ let s = raw.trim();
4570
+ if (!s)
4571
+ return null;
4572
+ const fence = s.match(/^```(?:json)?\s*([\s\S]*?)\s*```\s*$/);
4573
+ if (fence?.[1])
4574
+ s = fence[1].trim();
4575
+ if (!s.startsWith("{") && !s.startsWith("["))
4576
+ return null;
4577
+ let depthObj = 0;
4578
+ let depthArr = 0;
4579
+ let inString = false;
4580
+ let isEscaped = false;
4581
+ for (let i = 0;i < s.length; i++) {
4582
+ const ch = s[i];
4583
+ if (isEscaped) {
4584
+ isEscaped = false;
4585
+ continue;
4586
+ }
4587
+ if (ch === "\\") {
4588
+ isEscaped = true;
4589
+ continue;
4590
+ }
4591
+ if (ch === '"') {
4592
+ inString = !inString;
4593
+ continue;
4594
+ }
4595
+ if (inString)
4596
+ continue;
4597
+ if (ch === "{")
4598
+ depthObj++;
4599
+ else if (ch === "}")
4600
+ depthObj--;
4601
+ else if (ch === "[")
4602
+ depthArr++;
4603
+ else if (ch === "]")
4604
+ depthArr--;
4605
+ }
4606
+ if (inString)
4607
+ s += '"';
4608
+ while (depthArr > 0) {
4609
+ s += "]";
4610
+ depthArr--;
4611
+ }
4612
+ while (depthObj > 0) {
4613
+ s += "}";
4614
+ depthObj--;
4615
+ }
4616
+ return s;
4513
4617
  }
4514
4618
  async function callMessage(params) {
4515
4619
  const provider = getProvider();
@@ -4794,15 +4898,55 @@ function compactHtmlForRecovery(html, maxChars = 12000) {
4794
4898
  return html.slice(0, maxChars);
4795
4899
  }
4796
4900
  }
4901
+ var RECOVERY_SYSTEM_PROMPT = `
4902
+ Você ajuda a recuperar passos de teste E2E (Playwright) que falharam contra sites de e-commerce.
4903
+
4904
+ 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.
4905
+
4906
+ REGRAS CRÍTICAS:
4907
+
4908
+ 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.
4909
+
4910
+ 2. **Se o step é 'go-checkout' ou 'go-checkout-retry':**
4911
+ - 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".
4912
+ - **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.
4913
+ - **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:
4914
+ a. Qualificação por **texto**: \`a[href*="checkout"]:has-text('Finalizar')\`, \`a[href*="checkout"]:has-text('Continuar')\`
4915
+ b. Qualificação por **scope**: \`[role='dialog'] a[href*="checkout"]\`, \`[class*='minicart' i] a[href*="checkout"]\`, \`.cart-drawer a[href*="checkout"]\`
4916
+ Selectors GENÉRICOS sem qualificador (apenas \`a[href*="checkout"]\` sozinho) casam o ícone do header e devem ser EVITADOS.
4917
+ - Se NENHUM elemento com texto/scope plausível existe no HTML, retorne selector="" para sinalizar incerteza honestamente.
4918
+
4919
+ 3. **Se o step é 'shipping-calc-cart' ou 'shipping-calc-pdp':**
4920
+ - O alvo é um \`<input>\` (não button/link) visível com label/placeholder mencionando "CEP", "Frete", "Calcular", "Entrega", "Zip".
4921
+ - Prefira inputs cujo container/pai está visível (não escondido em accordion fechado).
4922
+
4923
+ 4. **Seletores preferidos** (nesta ordem):
4924
+ - Text-based Playwright: \`button:has-text('Finalizar')\`, \`a:has-text('Continuar para pagamento')\`
4925
+ - data-attributes específicos: \`[data-checkout-button]\`, \`[data-cart-finalize]\`, \`[data-fs-cart-button]\`
4926
+ - ARIA: \`[aria-label*='finalizar' i]\`, \`[role='button'][aria-label*='checkout' i]\`
4927
+ - Classes específicas de plataforma: \`.vtex-minicart-2-x-checkoutButton\`, \`.fs-cart__checkout\`
4928
+ - Por último, escopo hierárquico curto: \`[role='dialog'] button.cta\`, \`.minicart-drawer button[type='submit']\`
4929
+
4930
+ 5. **EVITE:**
4931
+ - IDs hash-gerados (\`#r-abc123\`)
4932
+ - Tailwind utility classes encadeadas (\`.flex.items-center.bg-blue\`)
4933
+ - Seletores genéricos sem qualificador (\`a[href*=...]\`, \`button[type=submit]\` sozinho)
4934
+ - Seletores que casam o ELEMENTO ERRADO em outra parte do DOM (ex: link de "checkout your reviews" no footer)
4935
+ - Seletores muito profundos (>4 níveis hierárquicos)
4936
+
4937
+ 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.
4938
+
4939
+ Responda SEMPRE via tool_use suggest_recovery.
4940
+ `.trim();
4797
4941
  async function suggestRecovery(input) {
4798
4942
  const compacted = compactHtmlForRecovery(input.html);
4799
4943
  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.",
4944
+ systemPrompt: RECOVERY_SYSTEM_PROMPT,
4801
4945
  userText: `Step: ${input.stepName}
4802
4946
  Ação desejada: ${input.intendedAction}
4803
- ${input.alreadyTried?.length ? `Já tentei: ${input.alreadyTried.join(", ")}
4947
+ ${input.alreadyTried?.length ? `Já tentei (não repita estes): ${input.alreadyTried.join(", ")}
4804
4948
  ` : ""}
4805
- HTML compactado:
4949
+ HTML compactado da página NESTE momento:
4806
4950
  \`\`\`html
4807
4951
  ${compacted}
4808
4952
  \`\`\``,
@@ -4813,9 +4957,9 @@ ${compacted}
4813
4957
  inputSchema: RECOVER_STEP_INPUT_SCHEMA
4814
4958
  }
4815
4959
  });
4816
- if (inp?.selector && inp.action) {
4960
+ if (inp?.selector?.trim() && inp.action) {
4817
4961
  return {
4818
- selector: inp.selector,
4962
+ selector: inp.selector.trim(),
4819
4963
  action: inp.action,
4820
4964
  value: inp.value,
4821
4965
  reasoning: inp.reasoning
@@ -4835,7 +4979,13 @@ var SelectorKey = z2.enum([
4835
4979
  "minicartTrigger",
4836
4980
  "cepInputPdp",
4837
4981
  "cepInputCart",
4838
- "checkoutButton"
4982
+ "checkoutButton",
4983
+ "sizeSwatch",
4984
+ "colorSwatch",
4985
+ "variantRow",
4986
+ "quantityIncrement",
4987
+ "quantityInput",
4988
+ "minicartCount"
4839
4989
  ]);
4840
4990
  var SelectorEntry = z2.object({
4841
4991
  selector: z2.string(),
@@ -5029,8 +5179,80 @@ var DEFAULT_SELECTORS = {
5029
5179
  "button:has-text('Finalizar compra')",
5030
5180
  "a:has-text('Ir para o checkout')",
5031
5181
  "button:has-text('Ir para o checkout')",
5182
+ "[role='dialog'] a:has-text('Finalizar')",
5183
+ "[role='dialog'] button:has-text('Finalizar')",
5184
+ "[data-minicart] a:has-text('Finalizar')",
5185
+ "[data-minicart] button:has-text('Finalizar')",
5186
+ "[data-cart-drawer] a:has-text('Finalizar')",
5187
+ "[data-cart-drawer] button:has-text('Finalizar')",
5032
5188
  "a:has-text('Checkout')",
5033
- "[data-checkout-button]"
5189
+ "[data-checkout-button]",
5190
+ "a:text-is('Finalizar')",
5191
+ "button:text-is('Finalizar')"
5192
+ ],
5193
+ sizeSwatch: [
5194
+ "[data-size]:not([disabled]):not([aria-disabled='true']):not(.unavailable):not(.sold-out)",
5195
+ "[data-variant-size]:not([disabled]):not([aria-disabled='true']):not(.unavailable)",
5196
+ "button[aria-label*='tamanho' i]:not([aria-disabled='true']):not([disabled])",
5197
+ "button[aria-label*='size' i]:not([aria-disabled='true']):not([disabled])",
5198
+ "[data-testid='size-selector'] button:not([disabled]):not(.unavailable)",
5199
+ ".size-selector button:not([disabled]):not(.unavailable):not(.sold-out)",
5200
+ "[class*='size'] button:not([disabled]):not(.unavailable)",
5201
+ "label:has(input[name*='size' i]:not([disabled])) "
5202
+ ],
5203
+ colorSwatch: [
5204
+ "[data-color]:not([disabled]):not([aria-disabled='true']):not(.unavailable):not(.sold-out)",
5205
+ "[data-variant-color]:not([disabled]):not([aria-disabled='true']):not(.unavailable)",
5206
+ "button[aria-label*='cor' i]:not([aria-disabled='true']):not([disabled])",
5207
+ "button[aria-label*='color' i]:not([aria-disabled='true']):not([disabled])",
5208
+ "[data-testid='color-selector'] button:not([disabled]):not(.unavailable)",
5209
+ ".color-swatch:not(.unavailable):not(.sold-out)",
5210
+ "[class*='color'] button:not([disabled]):not(.unavailable)"
5211
+ ],
5212
+ variantRow: [
5213
+ "[data-sku-row]",
5214
+ "[data-variant-row]",
5215
+ "tr[data-variant]",
5216
+ "tr:has([aria-label*='quantidade' i])",
5217
+ "tbody tr:has(input[type='number'])"
5218
+ ],
5219
+ quantityIncrement: [
5220
+ "[data-qty-plus]",
5221
+ "[data-quantity-plus]",
5222
+ "button[aria-label*='aumentar' i]",
5223
+ "button[aria-label*='increase' i]",
5224
+ "button[aria-label*='increment' i]",
5225
+ "button:has-text('+')",
5226
+ "[class*='qty'] button:has-text('+')"
5227
+ ],
5228
+ quantityInput: [
5229
+ "input[type='number'][min='0'][value='0']",
5230
+ "input[id*='quantity-input' i]",
5231
+ "input[name*='qty' i]",
5232
+ "input[name*='quantity' i]",
5233
+ "input[name*='quantidade' i]",
5234
+ "input[aria-label*='quantidade' i]",
5235
+ "[data-qty-input]",
5236
+ "input[type='number'][min='0']",
5237
+ "input[type='number'][min='1']"
5238
+ ],
5239
+ minicartCount: [
5240
+ "[data-minicart-count]",
5241
+ "[data-cart-count]",
5242
+ ".cart-count",
5243
+ ".minicart__count",
5244
+ "[class*='cart-count']",
5245
+ "[class*='CartCount']",
5246
+ "[aria-label*='itens' i][role='status']",
5247
+ "header [data-minicart-trigger] [class*='badge']",
5248
+ "header [aria-label*='cart' i] [class*='count']",
5249
+ "header [aria-label*='sacola' i] [class*='badge']",
5250
+ "header [aria-label*='sacola' i] [class*='count']",
5251
+ "header a[href*='/cart'] [class*='count']",
5252
+ "header a[href*='/checkout'] [class*='count']",
5253
+ "[data-fs-cart-icon] + [class*='count']",
5254
+ "[class*='Minicart'] [class*='count']",
5255
+ "[class*='minicart'] [class*='count']"
5034
5256
  ]
5035
5257
  };
5036
5258
  function selectorsFor(key, ctxOrRc = {}) {
@@ -5055,7 +5277,51 @@ function isResolutionContext(v) {
5055
5277
  }
5056
5278
 
5057
5279
  // src/engine/flows.ts
5058
- var PURCHASE_JOURNEY_TOTAL_STEPS = 8;
5280
+ var PURCHASE_JOURNEY_TOTAL_STEPS = 9;
5281
+ var DEBUG_PARITY2 = process.env.DEBUG_PARITY === "1" || process.env.DEBUG_PARITY === "true";
5282
+ var DEBUG_START2 = Date.now();
5283
+ function dlog2(ctx, msg) {
5284
+ if (!DEBUG_PARITY2)
5285
+ return;
5286
+ const elapsed = ((Date.now() - DEBUG_START2) / 1000).toFixed(1);
5287
+ process.stderr.write(`[+${elapsed}s ${ctx.viewport}/${ctx.side}] ${msg}
5288
+ `);
5289
+ }
5290
+ function withCap(p, capMs, fallback) {
5291
+ return Promise.race([
5292
+ p.catch(() => fallback),
5293
+ new Promise((resolve) => setTimeout(() => resolve(fallback), capMs))
5294
+ ]);
5295
+ }
5296
+ var VARIANT_REQUIRED_TEXT_PATTERNS = [
5297
+ /selecione um produto/i,
5298
+ /selecione um tamanho/i,
5299
+ /selecione uma cor/i,
5300
+ /selecione uma op[cç][aã]o/i,
5301
+ /select a size/i,
5302
+ /select a color/i,
5303
+ /select an option/i,
5304
+ /choose an option/i,
5305
+ /please select/i,
5306
+ /select size/i,
5307
+ /select color/i
5308
+ ];
5309
+ var ADD_TO_CART_ERROR_PATTERNS = [
5310
+ ...VARIANT_REQUIRED_TEXT_PATTERNS,
5311
+ /estoque esgotado/i,
5312
+ /out of stock/i,
5313
+ /indispon[ií]vel/i,
5314
+ /unavailable/i
5315
+ ];
5316
+ var ADD_TO_CART_SUCCESS_PATTERNS = [
5317
+ /produto adicionado/i,
5318
+ /adicionado ao carrinho/i,
5319
+ /adicionado [aà]\s+sacola/i,
5320
+ /added to cart/i,
5321
+ /added to bag/i,
5322
+ /item added/i,
5323
+ /successfully added/i
5324
+ ];
5059
5325
  function selFor(ctx, key) {
5060
5326
  return selectorsFor(key, { rc: ctx.rc, learned: ctx.learned, platform: ctx.platform });
5061
5327
  }
@@ -5063,7 +5329,7 @@ var FLOW_DEADLINE_MS = {
5063
5329
  homepage: 90000,
5064
5330
  plp: 180000,
5065
5331
  pdp: 240000,
5066
- "purchase-journey": 360000
5332
+ "purchase-journey": 420000
5067
5333
  };
5068
5334
  async function runFlow(flow, ctx) {
5069
5335
  const start = Date.now();
@@ -5094,16 +5360,23 @@ async function runFlow(flow, ctx) {
5094
5360
  resolve(finalize(flow, ctx, [], [
5095
5361
  {
5096
5362
  step: 0,
5097
- name: "visit-home",
5363
+ name: "flow-timeout",
5098
5364
  side: ctx.side,
5099
5365
  viewport: ctx.viewport,
5100
5366
  status: "failed",
5101
5367
  durationMs: deadlineMs,
5102
5368
  screenshotPath: "",
5103
- 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.`
5369
+ actionDescription: `[flow-timeout] flow "${flow}" excedeu ${deadlineMs}ms — captura abortada pela safety net externa, ${pages.length} page(s) fechada(s) para liberar o contexto.`
5104
5370
  }
5105
5371
  ], start));
5106
- cleanup = Promise.allSettled(pages.map((p) => p.close()));
5372
+ const CLOSE_CAP_MS = 5000;
5373
+ const cappedClose = (p) => Promise.race([
5374
+ p.close().catch(() => {
5375
+ return;
5376
+ }),
5377
+ new Promise((resolveClose) => setTimeout(resolveClose, CLOSE_CAP_MS))
5378
+ ]);
5379
+ cleanup = Promise.allSettled(pages.map(cappedClose));
5107
5380
  }, deadlineMs);
5108
5381
  });
5109
5382
  try {
@@ -5209,7 +5482,7 @@ async function flowPurchaseJourney(ctx) {
5209
5482
  const pages = [];
5210
5483
  const steps = [];
5211
5484
  const page = await ctx.ctx.newPage();
5212
- let recoveryBudget = ctx.recoveryBudget ?? 3;
5485
+ const budget = { remaining: ctx.recoveryBudget ?? 5 };
5213
5486
  const total = PURCHASE_JOURNEY_TOTAL_STEPS;
5214
5487
  const reportStart = (idx, name) => {
5215
5488
  ctx.onStep?.({ phase: "start", name, index: idx, total });
@@ -5274,9 +5547,35 @@ async function flowPurchaseJourney(ctx) {
5274
5547
  steps[steps.length - 1].actionDescription = `Navegou pra categoria \`${plpHit.url}\` (via \`${plpHit.selector}\`)`;
5275
5548
  steps[steps.length - 1].beforeUrl = ctx.baseUrl;
5276
5549
  reportStart(3, "enter-pdp");
5277
- const pdpHit = await findProductUrl(page, ctx);
5550
+ let pdpHit = await findProductUrl(page, ctx);
5551
+ let pdpRecoveredByLlm = false;
5552
+ if (!pdpHit && budget.remaining > 0) {
5553
+ const html = await page.content().catch(() => "");
5554
+ if (html) {
5555
+ const suggestion = await suggestRecovery({
5556
+ stepName: "enter-pdp",
5557
+ intendedAction: "Encontrar um link <a> que leve para a página de detalhes (PDP) de algum produto listado na PLP atual",
5558
+ html,
5559
+ alreadyTried: selFor(ctx, "productCard")
5560
+ });
5561
+ if (suggestion) {
5562
+ budget.remaining--;
5563
+ try {
5564
+ const el = page.locator(suggestion.selector).first();
5565
+ if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
5566
+ const href = await el.getAttribute("href");
5567
+ if (href) {
5568
+ const abs = new URL(href, page.url()).toString();
5569
+ pdpHit = { url: abs, selector: suggestion.selector };
5570
+ pdpRecoveredByLlm = true;
5571
+ }
5572
+ }
5573
+ } catch {}
5574
+ }
5575
+ }
5576
+ }
5278
5577
  if (!pdpHit) {
5279
- steps.push(makeSkipStep(3, "enter-pdp", ctx, "no product card found"));
5578
+ steps.push(makeSkipStep(3, "enter-pdp", ctx, "no product card found (recovery exhausted)"));
5280
5579
  reportEnd(3, "enter-pdp", "skipped", 0, "no product card found");
5281
5580
  return { pages, steps };
5282
5581
  }
@@ -5299,272 +5598,466 @@ async function flowPurchaseJourney(ctx) {
5299
5598
  url: pdpCap.finalUrl,
5300
5599
  screenshotPath: pdpCap.screenshotPath,
5301
5600
  selectorKey: "productCard",
5302
- usedSelector: pdpHit.selector
5601
+ usedSelector: pdpHit.selector,
5602
+ recoveredByLlm: pdpRecoveredByLlm || undefined
5303
5603
  });
5304
5604
  reportEnd(3, "enter-pdp", step3Status, Date.now() - t3);
5305
- steps[steps.length - 1].actionDescription = `Abriu PDP \`${pdpHit.url}\` (via \`${pdpHit.selector}\`)`;
5605
+ steps[steps.length - 1].actionDescription = `Abriu PDP \`${pdpHit.url}\` (via \`${pdpHit.selector}\`${pdpRecoveredByLlm ? " — recovery LLM" : ""})`;
5306
5606
  steps[steps.length - 1].beforeUrl = plpHit.url;
5307
- reportStart(4, "shipping-calc-pdp");
5607
+ const expectedProductTitle = await extractProductTitle(page);
5608
+ dlog2(ctx, `step 3 enter-pdp: extracted product title → ${expectedProductTitle ? `"${expectedProductTitle.slice(0, 60)}"` : "null"}`);
5609
+ if (expectedProductTitle) {
5610
+ steps[steps.length - 1].detail = {
5611
+ ...steps[steps.length - 1].detail ?? {},
5612
+ productTitle: expectedProductTitle
5613
+ };
5614
+ }
5615
+ reportStart(4, "select-variant");
5616
+ const t4 = Date.now();
5617
+ const beforeUrl4 = page.url();
5618
+ const spBefore4 = screenshotPath(ctx, "pj-4-select-variant-before");
5619
+ await page.screenshot({ path: spBefore4, fullPage: false }).catch(() => {
5620
+ return;
5621
+ });
5622
+ const variantResult = await selectVariant(page, ctx);
5623
+ let variantLlmAction = null;
5624
+ if (variantResult.actions.length === 0 && variantResult.variantRequired && budget.remaining > 0) {
5625
+ variantLlmAction = await attemptStepAction({
5626
+ page,
5627
+ ctx,
5628
+ stepName: "select-variant",
5629
+ intendedAction: "Selecionar uma variante disponível (tamanho, cor, sabor) ou clicar no botão '+' para incrementar a quantidade da primeira variante listada na PDP. Não clique no botão de comprar.",
5630
+ selectorKey: "quantityIncrement",
5631
+ action: "click",
5632
+ recoveryBudget: budget
5633
+ });
5634
+ }
5635
+ const sp4 = screenshotPath(ctx, "pj-4-select-variant");
5636
+ await page.screenshot({ path: sp4, fullPage: false }).catch(() => {
5637
+ return;
5638
+ });
5639
+ if (variantResult.actions.length > 0 || variantLlmAction?.performed) {
5640
+ const llmDesc = variantLlmAction?.performed ? `Recovery LLM: ${variantLlmAction.action} em \`${variantLlmAction.selector}\`` : null;
5641
+ const desc = variantResult.actions.length > 0 ? variantResult.actions.join("; ") : llmDesc ?? "(variante selecionada)";
5642
+ steps.push({
5643
+ step: 4,
5644
+ name: "select-variant",
5645
+ side: ctx.side,
5646
+ viewport: ctx.viewport,
5647
+ status: "ok",
5648
+ durationMs: Date.now() - t4,
5649
+ url: page.url(),
5650
+ screenshotPath: sp4,
5651
+ screenshotBeforePath: spBefore4,
5652
+ beforeUrl: beforeUrl4,
5653
+ actionDescription: llmDesc && variantResult.actions.length > 0 ? `${desc}; ${llmDesc}` : desc,
5654
+ selectorKey: variantLlmAction?.performed ? "quantityIncrement" : variantResult.primarySelectorKey,
5655
+ usedSelector: variantLlmAction?.performed ? variantLlmAction.selector : variantResult.primarySelector,
5656
+ recoveredByLlm: variantLlmAction?.recoveredByLlm || undefined,
5657
+ detail: { actions: variantResult.actions, llmRecovery: !!variantLlmAction?.performed }
5658
+ });
5659
+ reportEnd(4, "select-variant", "ok", Date.now() - t4);
5660
+ } else {
5661
+ const skipNote = variantResult.variantRequired ? "variant required by store but no selectors matched (LLM recovery also failed)" : "no variant selectors found on PDP (assuming single-SKU product)";
5662
+ steps.push(makeSkipStep(4, "select-variant", ctx, skipNote));
5663
+ reportEnd(4, "select-variant", "skipped", 0, skipNote);
5664
+ }
5665
+ reportStart(5, "shipping-calc-pdp");
5308
5666
  let cepInputPdp = await firstVisible(page, selFor(ctx, "cepInputPdp"));
5309
- let cepPdpRecovered = false;
5310
- if (!cepInputPdp && recoveryBudget > 0) {
5311
- 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"));
5312
- if (recovery) {
5313
- cepInputPdp = recovery.selector;
5314
- cepPdpRecovered = true;
5315
- recoveryBudget--;
5667
+ let cepPdpRecoveredByLlm = false;
5668
+ if (!cepInputPdp && budget.remaining > 0) {
5669
+ const html = await page.content().catch(() => "");
5670
+ if (html) {
5671
+ const suggestion = await suggestRecovery({
5672
+ stepName: "shipping-calc-pdp",
5673
+ intendedAction: "Encontre o <input> de CEP (código postal de ENDEREÇO de entrega) na PDP, usado pra CALCULAR FRETE — placeholder normalmente é 'CEP', 'Digite seu CEP', 'Cód. postal', 'Postal code', aria-label='CEP' ou name='zip'/'cep'/'postal'. **NÃO** retorne o input de CUPOM de desconto / código promocional / 'Digite o cupom' / 'Insira código' — esses são pra desconto, não pra frete.",
5674
+ html,
5675
+ alreadyTried: selFor(ctx, "cepInputPdp")
5676
+ });
5677
+ if (suggestion) {
5678
+ budget.remaining--;
5679
+ const el = page.locator(suggestion.selector).first();
5680
+ if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
5681
+ cepInputPdp = suggestion.selector;
5682
+ cepPdpRecoveredByLlm = true;
5683
+ }
5684
+ }
5316
5685
  }
5317
5686
  }
5318
5687
  if (cepInputPdp) {
5319
- const t4 = Date.now();
5320
- const beforeUrl4 = page.url();
5321
- const spBefore4 = screenshotPath(ctx, "pj-4-shipping-pdp-before");
5322
- await page.screenshot({ path: spBefore4, fullPage: false }).catch(() => {
5688
+ const t5 = Date.now();
5689
+ const beforeUrl5 = page.url();
5690
+ const spBefore5 = screenshotPath(ctx, "pj-5-shipping-pdp-before");
5691
+ await page.screenshot({ path: spBefore5, fullPage: false }).catch(() => {
5323
5692
  return;
5324
5693
  });
5325
5694
  const ok = await fillCep(page, cepInputPdp, ctx.rc.cep);
5326
- const sp = screenshotPath(ctx, "pj-4-shipping-pdp");
5695
+ const sp = screenshotPath(ctx, "pj-5-shipping-pdp");
5327
5696
  await page.screenshot({ path: sp, fullPage: false }).catch(() => {
5328
5697
  return;
5329
5698
  });
5330
- const step4Status = ok ? "ok" : "failed";
5699
+ const step5Status = ok ? "ok" : "failed";
5331
5700
  steps.push({
5332
- step: 4,
5701
+ step: 5,
5333
5702
  name: "shipping-calc-pdp",
5334
5703
  side: ctx.side,
5335
5704
  viewport: ctx.viewport,
5336
- status: step4Status,
5337
- durationMs: Date.now() - t4,
5705
+ status: step5Status,
5706
+ durationMs: Date.now() - t5,
5338
5707
  url: page.url(),
5339
5708
  screenshotPath: sp,
5340
- screenshotBeforePath: spBefore4,
5341
- beforeUrl: beforeUrl4,
5342
- actionDescription: `Preencheu CEP '${ctx.rc.cep}' no input \`${cepInputPdp}\` e disparou cálculo de frete${cepPdpRecovered ? " (selector via LLM recovery)" : ""}`,
5709
+ screenshotBeforePath: spBefore5,
5710
+ beforeUrl: beforeUrl5,
5711
+ actionDescription: `Preencheu CEP '${ctx.rc.cep}' no input \`${cepInputPdp}\`${cepPdpRecoveredByLlm ? " (via recovery LLM)" : ""}`,
5343
5712
  detail: { cepUsed: ctx.rc.cep },
5344
5713
  selectorKey: "cepInputPdp",
5345
5714
  usedSelector: cepInputPdp,
5346
- recoveredByLlm: cepPdpRecovered || undefined
5715
+ recoveredByLlm: cepPdpRecoveredByLlm || undefined
5347
5716
  });
5348
- reportEnd(4, "shipping-calc-pdp", step4Status, Date.now() - t4);
5717
+ reportEnd(5, "shipping-calc-pdp", step5Status, Date.now() - t5);
5349
5718
  } else {
5350
- steps.push(makeSkipStep(4, "shipping-calc-pdp", ctx, "no CEP input on PDP (recovery exhausted)"));
5351
- reportEnd(4, "shipping-calc-pdp", "skipped", 0, "no CEP input on PDP");
5719
+ steps.push(makeSkipStep(5, "shipping-calc-pdp", ctx, "no CEP input on PDP (recovery exhausted)"));
5720
+ reportEnd(5, "shipping-calc-pdp", "skipped", 0, "no CEP input on PDP");
5352
5721
  }
5353
- reportStart(5, "add-to-cart");
5722
+ reportStart(6, "add-to-cart");
5354
5723
  let buyHit = await firstVisibleLocator(page, selFor(ctx, "buyButton"));
5355
5724
  let buyRecovered = false;
5356
- if (!buyHit && recoveryBudget > 0) {
5725
+ if (!buyHit && budget.remaining > 0) {
5357
5726
  const recovery = await attemptRecovery(page, ctx, "add-to-cart", "Clicar no botão de comprar/adicionar ao carrinho", selFor(ctx, "buyButton"));
5358
5727
  if (recovery) {
5359
5728
  buyHit = recovery;
5360
5729
  buyRecovered = true;
5361
- recoveryBudget--;
5730
+ budget.remaining--;
5362
5731
  }
5363
5732
  }
5364
5733
  if (!buyHit) {
5365
- steps.push(makeSkipStep(5, "add-to-cart", ctx, "no buy button found (recovery exhausted)"));
5366
- reportEnd(5, "add-to-cart", "skipped", 0, "no buy button found");
5734
+ steps.push(makeSkipStep(6, "add-to-cart", ctx, "no buy button found (recovery exhausted)"));
5735
+ reportEnd(6, "add-to-cart", "skipped", 0, "no buy button found");
5367
5736
  return { pages, steps };
5368
5737
  }
5369
- const t5 = Date.now();
5370
- const beforeUrl5 = page.url();
5371
- const spBefore5 = screenshotPath(ctx, "pj-5-add-cart-before");
5372
- await page.screenshot({ path: spBefore5, fullPage: false }).catch(() => {
5738
+ const t6 = Date.now();
5739
+ const beforeUrl6 = page.url();
5740
+ const spBefore6 = screenshotPath(ctx, "pj-6-add-cart-before");
5741
+ await page.screenshot({ path: spBefore6, fullPage: false }).catch(() => {
5373
5742
  return;
5374
5743
  });
5744
+ const cartCountBefore = await readCartCount(page, ctx);
5375
5745
  const buyText = await buyHit.locator.innerText().catch(() => "");
5376
5746
  await buyHit.locator.click({ timeout: 5000 }).catch(() => {
5377
5747
  return;
5378
5748
  });
5379
- await page.waitForTimeout(2000);
5380
- const sp5 = screenshotPath(ctx, "pj-5-add-cart");
5381
- await page.screenshot({ path: sp5, fullPage: false }).catch(() => {
5749
+ let validation = await validateAddToCart(page, ctx, cartCountBefore, beforeUrl6);
5750
+ let variantRetryNote;
5751
+ if (validation.status === "failed" && validation.errorText && VARIANT_REQUIRED_TEXT_PATTERNS.some((re) => re.test(validation.errorText))) {
5752
+ const retryHit = await findAndIncrementZeroQtyInput(page, ctx);
5753
+ if (retryHit) {
5754
+ variantRetryNote = `Retry: ${retryHit.description}`;
5755
+ } else if (budget.remaining > 0) {
5756
+ const llmRetry = await attemptStepAction({
5757
+ page,
5758
+ ctx,
5759
+ stepName: "add-to-cart-retry-variant",
5760
+ intendedAction: "A loja rejeitou o add-to-cart com a mensagem 'Selecione um produto/tamanho/cor'. Encontre o picker de variante visível e selecione UMA opção (clicar num swatch, em '+' ao lado de uma quantidade '0', em um botão de tamanho). Não clique no botão de comprar.",
5761
+ selectorKey: "quantityIncrement",
5762
+ action: "click",
5763
+ recoveryBudget: budget
5764
+ });
5765
+ if (llmRetry.performed) {
5766
+ variantRetryNote = `Retry via LLM: ${llmRetry.action} em \`${llmRetry.selector}\``;
5767
+ }
5768
+ }
5769
+ if (variantRetryNote) {
5770
+ await page.waitForTimeout(500);
5771
+ await buyHit.locator.click({ timeout: 5000 }).catch(() => {
5772
+ return;
5773
+ });
5774
+ validation = await validateAddToCart(page, ctx, cartCountBefore, beforeUrl6);
5775
+ }
5776
+ }
5777
+ const sp6 = screenshotPath(ctx, "pj-6-add-cart");
5778
+ await page.screenshot({ path: sp6, fullPage: false }).catch(() => {
5382
5779
  return;
5383
5780
  });
5781
+ const buyActionDesc = `Clicou no botão${buyText ? ` '${buyText.slice(0, 40).trim()}'` : ""} (\`${buyHit.selector}\`)${buyRecovered ? " — selector veio de recovery LLM" : ""}`;
5782
+ const fullActionDesc = variantRetryNote ? `${buyActionDesc} — ${variantRetryNote} — ${validation.note}` : `${buyActionDesc} — ${validation.note}`;
5384
5783
  steps.push({
5385
- step: 5,
5784
+ step: 6,
5386
5785
  name: "add-to-cart",
5387
5786
  side: ctx.side,
5388
5787
  viewport: ctx.viewport,
5389
- status: "ok",
5390
- durationMs: Date.now() - t5,
5788
+ status: validation.status,
5789
+ durationMs: Date.now() - t6,
5391
5790
  url: page.url(),
5392
- screenshotPath: sp5,
5393
- screenshotBeforePath: spBefore5,
5394
- beforeUrl: beforeUrl5,
5395
- actionDescription: `Clicou no botão${buyText ? ` '${buyText.slice(0, 40).trim()}'` : ""} (\`${buyHit.selector}\`)${buyRecovered ? " — selector veio de recovery LLM" : ""}`,
5791
+ screenshotPath: sp6,
5792
+ screenshotBeforePath: spBefore6,
5793
+ beforeUrl: beforeUrl6,
5794
+ actionDescription: fullActionDesc,
5396
5795
  selectorKey: "buyButton",
5397
5796
  usedSelector: buyHit.selector,
5398
- recoveredByLlm: buyRecovered || undefined
5797
+ recoveredByLlm: buyRecovered || undefined,
5798
+ note: validation.status === "ok" ? undefined : validation.note,
5799
+ detail: { signal: validation.signal, errorText: validation.errorText, variantRetry: variantRetryNote }
5399
5800
  });
5400
- reportEnd(5, "add-to-cart", "ok", Date.now() - t5);
5401
- reportStart(6, "open-minicart");
5402
- const t6 = Date.now();
5403
- const beforeUrl6 = page.url();
5404
- const spBefore6 = screenshotPath(ctx, "pj-6-minicart-before");
5405
- await page.screenshot({ path: spBefore6, fullPage: false }).catch(() => {
5801
+ reportEnd(6, "add-to-cart", validation.status, Date.now() - t6, validation.status === "ok" ? undefined : validation.note);
5802
+ if (validation.status === "failed") {
5803
+ return { pages, steps };
5804
+ }
5805
+ reportStart(7, "open-minicart");
5806
+ const t7 = Date.now();
5807
+ const beforeUrl7 = page.url();
5808
+ const spBefore7 = screenshotPath(ctx, "pj-7-minicart-before");
5809
+ await page.screenshot({ path: spBefore7, fullPage: false }).catch(() => {
5406
5810
  return;
5407
5811
  });
5408
5812
  let miniHit = await firstVisibleLocator(page, selFor(ctx, "minicartTrigger"));
5409
5813
  let miniRecovered = false;
5410
- if (!miniHit && recoveryBudget > 0) {
5814
+ if (!miniHit && budget.remaining > 0) {
5411
5815
  const recovery = await attemptRecovery(page, ctx, "open-minicart", "Abrir o minicart/drawer do carrinho", selFor(ctx, "minicartTrigger"));
5412
5816
  if (recovery) {
5413
5817
  miniHit = recovery;
5414
5818
  miniRecovered = true;
5415
- recoveryBudget--;
5819
+ budget.remaining--;
5416
5820
  }
5417
5821
  }
5822
+ let cartOpenMethod = "failed";
5418
5823
  let miniText = "";
5419
5824
  if (miniHit) {
5420
5825
  miniText = await miniHit.locator.innerText().catch(() => "");
5421
- await miniHit.locator.click({ timeout: 3000 }).catch(() => {
5422
- return;
5423
- });
5424
- await page.waitForTimeout(1500);
5826
+ const openResult = await openMinicart(page, miniHit, ctx, expectedProductTitle);
5827
+ cartOpenMethod = openResult.method;
5828
+ } else {
5829
+ const alreadyOpen = await isCartRevealed(page, expectedProductTitle);
5830
+ if (alreadyOpen) {
5831
+ cartOpenMethod = "already-open";
5832
+ dlog2(ctx, `step 7 open-minicart: no trigger, drawer already visible (${alreadyOpen})`);
5833
+ }
5425
5834
  }
5426
- const sp6 = screenshotPath(ctx, "pj-6-minicart");
5427
- await page.screenshot({ path: sp6, fullPage: false }).catch(() => {
5835
+ let step7Validation;
5836
+ if (expectedProductTitle && cartOpenMethod !== "failed") {
5837
+ const v = await validateCartContainsTitle(page, expectedProductTitle, ctx);
5838
+ let reasonText;
5839
+ if (!v.found) {
5840
+ if (v.observedTitles.length === 0) {
5841
+ const emptyBanner = await detectEmptyCartBanner(page);
5842
+ reasonText = emptyBanner ? `cart genuinely empty — add-to-cart didn't persist. Banner: "${emptyBanner}"` : "no cart line items visible (selectors may not match markup)";
5843
+ } else {
5844
+ reasonText = `expected title not found among ${v.observedTitles.length} observed`;
5845
+ }
5846
+ }
5847
+ step7Validation = {
5848
+ expectedTitle: expectedProductTitle,
5849
+ found: v.found,
5850
+ method: v.method,
5851
+ observedTitles: v.observedTitles.slice(0, 8),
5852
+ reason: reasonText
5853
+ };
5854
+ dlog2(ctx, `step 7 open-minicart: validation → found=${v.found} (${v.method})${reasonText ? ` — ${reasonText.slice(0, 80)}` : ""}`);
5855
+ }
5856
+ const sp7 = screenshotPath(ctx, "pj-7-minicart");
5857
+ await page.screenshot({ path: sp7, fullPage: false }).catch(() => {
5428
5858
  return;
5429
5859
  });
5860
+ const step7Status = cartOpenMethod === "failed" ? "failed" : step7Validation && !step7Validation.found ? "failed" : "ok";
5430
5861
  steps.push({
5431
- step: 6,
5862
+ step: 7,
5432
5863
  name: "open-minicart",
5433
5864
  side: ctx.side,
5434
5865
  viewport: ctx.viewport,
5435
- status: "ok",
5436
- durationMs: Date.now() - t6,
5866
+ status: step7Status,
5867
+ durationMs: Date.now() - t7,
5437
5868
  url: page.url(),
5438
- screenshotPath: sp6,
5439
- screenshotBeforePath: spBefore6,
5440
- 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)",
5869
+ screenshotPath: sp7,
5870
+ screenshotBeforePath: spBefore7,
5871
+ beforeUrl: beforeUrl7,
5872
+ cartOpenMethod,
5873
+ cartValidation: step7Validation,
5874
+ actionDescription: miniHit ? `Abriu minicart via ${cartOpenMethod}${miniText ? ` em '${miniText.slice(0, 30).trim()}'` : ""} (\`${miniHit.selector}\`)${miniRecovered ? " — selector via recovery LLM" : ""}${step7Validation ? step7Validation.found ? " ✓ produto encontrado no cart" : ` ✗ produto ESPERADO não encontrado (${step7Validation.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
5875
  selectorKey: miniHit ? "minicartTrigger" : undefined,
5443
5876
  usedSelector: miniHit?.selector,
5444
5877
  recoveredByLlm: miniRecovered || undefined
5445
5878
  });
5446
- reportEnd(6, "open-minicart", "ok", Date.now() - t6);
5447
- reportStart(7, "shipping-calc-cart");
5879
+ reportEnd(7, "open-minicart", step7Status, Date.now() - t7);
5880
+ reportStart(8, "shipping-calc-cart");
5448
5881
  let cepInputCart = await firstVisible(page, selFor(ctx, "cepInputCart"));
5449
- let cepCartRecovered = false;
5450
- if (!cepInputCart && recoveryBudget > 0) {
5451
- 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"));
5452
- if (recovery) {
5453
- cepInputCart = recovery.selector;
5454
- cepCartRecovered = true;
5455
- recoveryBudget--;
5882
+ let cepCartRecoveredByLlm = false;
5883
+ if (!cepInputCart && budget.remaining > 0) {
5884
+ const html = await page.content().catch(() => "");
5885
+ if (html) {
5886
+ const suggestion = await suggestRecovery({
5887
+ stepName: "shipping-calc-cart",
5888
+ intendedAction: "Encontre o <input> de CEP (código postal de ENDEREÇO de entrega) dentro do minicart drawer ou da página de carrinho — usado pra CALCULAR FRETE antes do checkout. Placeholder normalmente é 'CEP', 'Digite seu CEP', 'Cód. postal', 'Postal code', aria-label='CEP' ou name='zip'/'cep'/'postal'. **NÃO** retorne o input de CUPOM de desconto / 'Digite o cupom' / código promocional — esses são pra desconto, não pra frete. Também NÃO retorne campo de e-mail/newsletter.",
5889
+ html,
5890
+ alreadyTried: selFor(ctx, "cepInputCart")
5891
+ });
5892
+ if (suggestion) {
5893
+ budget.remaining--;
5894
+ const el = page.locator(suggestion.selector).first();
5895
+ if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
5896
+ cepInputCart = suggestion.selector;
5897
+ cepCartRecoveredByLlm = true;
5898
+ }
5899
+ }
5456
5900
  }
5457
5901
  }
5458
5902
  if (cepInputCart) {
5459
- const t7 = Date.now();
5460
- const beforeUrl7 = page.url();
5461
- const spBefore7 = screenshotPath(ctx, "pj-7-shipping-cart-before");
5462
- await page.screenshot({ path: spBefore7, fullPage: false }).catch(() => {
5903
+ const t8 = Date.now();
5904
+ const beforeUrl8 = page.url();
5905
+ const spBefore8 = screenshotPath(ctx, "pj-8-shipping-cart-before");
5906
+ await page.screenshot({ path: spBefore8, fullPage: false }).catch(() => {
5463
5907
  return;
5464
5908
  });
5465
5909
  const ok = await fillCep(page, cepInputCart, ctx.rc.cep);
5466
- const sp7 = screenshotPath(ctx, "pj-7-shipping-cart");
5467
- await page.screenshot({ path: sp7, fullPage: false }).catch(() => {
5910
+ const sp8 = screenshotPath(ctx, "pj-8-shipping-cart");
5911
+ await page.screenshot({ path: sp8, fullPage: false }).catch(() => {
5468
5912
  return;
5469
5913
  });
5470
- const step7Status = ok ? "ok" : "failed";
5914
+ const step8Status = ok ? "ok" : "failed";
5471
5915
  steps.push({
5472
- step: 7,
5916
+ step: 8,
5473
5917
  name: "shipping-calc-cart",
5474
5918
  side: ctx.side,
5475
5919
  viewport: ctx.viewport,
5476
- status: step7Status,
5477
- durationMs: Date.now() - t7,
5920
+ status: step8Status,
5921
+ durationMs: Date.now() - t8,
5478
5922
  url: page.url(),
5479
- screenshotPath: sp7,
5480
- screenshotBeforePath: spBefore7,
5481
- beforeUrl: beforeUrl7,
5482
- actionDescription: `Preencheu CEP '${ctx.rc.cep}' no carrinho (\`${cepInputCart}\`)${cepCartRecovered ? " (selector via LLM recovery)" : ""}`,
5923
+ screenshotPath: sp8,
5924
+ screenshotBeforePath: spBefore8,
5925
+ beforeUrl: beforeUrl8,
5926
+ actionDescription: `Preencheu CEP '${ctx.rc.cep}' no carrinho (\`${cepInputCart}\`)${cepCartRecoveredByLlm ? " recovery LLM" : ""}`,
5483
5927
  detail: { cepUsed: ctx.rc.cep },
5484
5928
  selectorKey: "cepInputCart",
5485
5929
  usedSelector: cepInputCart,
5486
- recoveredByLlm: cepCartRecovered || undefined
5930
+ recoveredByLlm: cepCartRecoveredByLlm || undefined
5487
5931
  });
5488
- reportEnd(7, "shipping-calc-cart", step7Status, Date.now() - t7);
5932
+ reportEnd(8, "shipping-calc-cart", step8Status, Date.now() - t8);
5489
5933
  } else {
5490
- steps.push(makeSkipStep(7, "shipping-calc-cart", ctx, "no CEP input in cart (recovery exhausted)"));
5491
- reportEnd(7, "shipping-calc-cart", "skipped", 0, "no CEP input in cart");
5934
+ steps.push(makeSkipStep(8, "shipping-calc-cart", ctx, "no CEP input in cart (recovery exhausted)"));
5935
+ reportEnd(8, "shipping-calc-cart", "skipped", 0, "no CEP input in cart");
5936
+ }
5937
+ reportStart(9, "go-checkout");
5938
+ const t9 = Date.now();
5939
+ const beforeUrl9 = page.url();
5940
+ if (/\/(checkout|carrinho)(\/|$|\?)/i.test(beforeUrl9)) {
5941
+ await waitForCartHydration(page);
5942
+ let step9EarlyValidation;
5943
+ if (expectedProductTitle) {
5944
+ const v = await validateCartContainsTitle(page, expectedProductTitle, ctx);
5945
+ step9EarlyValidation = {
5946
+ expectedTitle: expectedProductTitle,
5947
+ found: v.found,
5948
+ method: v.method,
5949
+ observedTitles: v.observedTitles.slice(0, 8),
5950
+ reason: v.found ? undefined : `expected title not found among ${v.observedTitles.length} observed on checkout`
5951
+ };
5952
+ }
5953
+ const sp9early = screenshotPath(ctx, "pj-9-checkout-reached");
5954
+ await page.screenshot({ path: sp9early, fullPage: false }).catch(() => {
5955
+ return;
5956
+ });
5957
+ const earlyStatus = step9EarlyValidation && !step9EarlyValidation.found ? "failed" : "ok";
5958
+ steps.push({
5959
+ step: 9,
5960
+ name: "go-checkout",
5961
+ side: ctx.side,
5962
+ viewport: ctx.viewport,
5963
+ status: earlyStatus,
5964
+ durationMs: Date.now() - t9,
5965
+ url: page.url(),
5966
+ screenshotPath: sp9early,
5967
+ cartValidation: step9EarlyValidation,
5968
+ actionDescription: `Já estava em \`${beforeUrl9}\` (minicart navegou direto para checkout)${step9EarlyValidation ? step9EarlyValidation.found ? " ✓ produto persiste no checkout" : ` ✗ produto ESPERADO ausente (${step9EarlyValidation.observedTitles?.length ?? 0} observados)` : ""}`,
5969
+ detail: { reachedVia: "minicart-direct-link" }
5970
+ });
5971
+ reportEnd(9, "go-checkout", earlyStatus, Date.now() - t9);
5972
+ return { pages, steps };
5492
5973
  }
5493
- reportStart(8, "go-checkout");
5494
5974
  let checkoutHit = await firstVisibleLocator(page, selFor(ctx, "checkoutButton"));
5495
5975
  let checkoutRecovered = false;
5496
- 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"));
5976
+ if (!checkoutHit && budget.remaining > 0) {
5977
+ const recovery = await attemptRecovery(page, ctx, "go-checkout", "Clicar no botão 'Finalizar compra' / 'Ir para o checkout' / 'Finalizar'", selFor(ctx, "checkoutButton"));
5498
5978
  if (recovery) {
5499
5979
  checkoutHit = recovery;
5500
5980
  checkoutRecovered = true;
5501
- recoveryBudget--;
5981
+ budget.remaining--;
5502
5982
  }
5503
5983
  }
5504
5984
  if (!checkoutHit) {
5505
- steps.push(makeSkipStep(8, "go-checkout", ctx, "no checkout button found (recovery exhausted)"));
5506
- reportEnd(8, "go-checkout", "skipped", 0, "no checkout button found");
5985
+ steps.push(makeSkipStep(9, "go-checkout", ctx, "no checkout button found (recovery exhausted)"));
5986
+ reportEnd(9, "go-checkout", "skipped", 0, "no checkout button found");
5507
5987
  return { pages, steps };
5508
5988
  }
5509
- const tryCheckoutClick = async (hit, attempt2) => {
5510
- const spBefore = screenshotPath(ctx, `pj-8-checkout-before-${attempt2}`);
5511
- await page.screenshot({ path: spBefore, fullPage: false }).catch(() => {
5512
- return;
5513
- });
5514
- const clickedText2 = await hit.locator.innerText().catch(() => "");
5989
+ const spBefore9 = screenshotPath(ctx, "pj-9-checkout-before");
5990
+ await page.screenshot({ path: spBefore9, fullPage: false }).catch(() => {
5991
+ return;
5992
+ });
5993
+ let usedSelector = checkoutHit.selector;
5994
+ let clickedText = await checkoutHit.locator.innerText().catch(() => "");
5995
+ let reachedCheckout = false;
5996
+ let attempt = 0;
5997
+ const triedSelectors = new Set([usedSelector]);
5998
+ while (attempt < 3) {
5999
+ attempt++;
5515
6000
  await Promise.all([
5516
- page.waitForURL(/checkout/i, { timeout: 1e4 }).catch(() => {
6001
+ page.waitForURL(/\/(checkout|carrinho)/i, { timeout: 1e4 }).catch(() => {
5517
6002
  return;
5518
6003
  }),
5519
- hit.locator.click({ timeout: 5000 }).catch(() => {
6004
+ checkoutHit.locator.click({ timeout: 5000 }).catch(() => {
5520
6005
  return;
5521
6006
  })
5522
6007
  ]);
5523
6008
  await page.waitForTimeout(1500);
5524
- const spAfter = screenshotPath(ctx, `pj-8-checkout-reached-${attempt2}`);
5525
- await page.screenshot({ path: spAfter, fullPage: false }).catch(() => {
5526
- return;
5527
- });
5528
- return { url: page.url(), spBefore, spAfter, clickedText: clickedText2 };
5529
- };
5530
- const t8 = Date.now();
5531
- const beforeUrl8 = page.url();
5532
- let attempt = 1;
5533
- let result = await tryCheckoutClick(checkoutHit, attempt);
5534
- let reachedCheckout = /\/checkout/i.test(result.url);
5535
- let usedSelector = checkoutHit.selector;
5536
- 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")]);
5539
- if (retrySuggestion) {
5540
- recoveryBudget--;
5541
- checkoutRecovered = true;
5542
- attempt++;
5543
- const retryResult = await tryCheckoutClick(retrySuggestion, attempt);
5544
- result = retryResult;
5545
- usedSelector = retrySuggestion.selector;
5546
- clickedText = retryResult.clickedText;
5547
- reachedCheckout = /\/checkout/i.test(retryResult.url);
5548
- }
6009
+ reachedCheckout = /\/(checkout|carrinho)(\/|$|\?)/i.test(page.url());
6010
+ if (reachedCheckout)
6011
+ break;
6012
+ if (budget.remaining <= 0)
6013
+ break;
6014
+ dlog2(ctx, `step 9 go-checkout: click on \`${usedSelector}\` didn't navigate (attempt ${attempt}). URL still ${page.url()}. Asking LLM for another selector.`);
6015
+ const triedList = Array.from(triedSelectors);
6016
+ const recovery = await attemptRecovery(page, ctx, "go-checkout", `O click anterior em \`${usedSelector}\` não navegou para /checkout (URL continua em ${page.url()}). Encontre OUTRO selector — o botão real de finalizar compra deve ser visível agora (no drawer aberto ou na página). Não retorne ${triedList.join(", ")} novamente.`, triedList);
6017
+ if (!recovery)
6018
+ break;
6019
+ checkoutHit = recovery;
6020
+ checkoutRecovered = true;
6021
+ usedSelector = recovery.selector;
6022
+ triedSelectors.add(usedSelector);
6023
+ clickedText = await recovery.locator.innerText().catch(() => "");
6024
+ budget.remaining--;
6025
+ }
6026
+ let step9Validation;
6027
+ if (reachedCheckout && expectedProductTitle) {
6028
+ await waitForCartHydration(page);
6029
+ const v = await validateCartContainsTitle(page, expectedProductTitle, ctx);
6030
+ step9Validation = {
6031
+ expectedTitle: expectedProductTitle,
6032
+ found: v.found,
6033
+ method: v.method,
6034
+ observedTitles: v.observedTitles.slice(0, 8),
6035
+ reason: v.found ? undefined : `expected title not found among ${v.observedTitles.length} observed on checkout`
6036
+ };
5549
6037
  }
5550
- const step8Status = reachedCheckout ? "ok" : "failed";
6038
+ const sp9 = screenshotPath(ctx, "pj-9-checkout-reached");
6039
+ await page.screenshot({ path: sp9, fullPage: false }).catch(() => {
6040
+ return;
6041
+ });
6042
+ const step9Status = !reachedCheckout ? "failed" : step9Validation && !step9Validation.found ? "failed" : "ok";
5551
6043
  steps.push({
5552
- step: 8,
6044
+ step: 9,
5553
6045
  name: "go-checkout",
5554
6046
  side: ctx.side,
5555
6047
  viewport: ctx.viewport,
5556
- status: step8Status,
5557
- durationMs: Date.now() - t8,
5558
- url: result.url,
5559
- screenshotPath: result.spAfter,
5560
- screenshotBeforePath: result.spBefore,
5561
- 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)` : ""}`,
6048
+ status: step9Status,
6049
+ durationMs: Date.now() - t9,
6050
+ url: page.url(),
6051
+ screenshotPath: sp9,
6052
+ screenshotBeforePath: spBefore9,
6053
+ beforeUrl: beforeUrl9,
6054
+ cartValidation: step9Validation,
6055
+ actionDescription: `Clicou em${clickedText ? ` '${clickedText.slice(0, 30).trim()}'` : ""} (\`${usedSelector}\`); URL final: ${page.url()}${reachedCheckout ? " ✓ atingiu checkout" : " ✗ não foi pra checkout"}${attempt > 1 ? ` (após ${attempt} tentativas com recovery LLM)` : ""}${step9Validation ? step9Validation.found ? " ✓ produto persiste no checkout" : ` ✗ produto ESPERADO ausente no checkout (${step9Validation.observedTitles?.length ?? 0} observados)` : ""}`,
5563
6056
  selectorKey: "checkoutButton",
5564
6057
  usedSelector,
5565
6058
  recoveredByLlm: checkoutRecovered || undefined
5566
6059
  });
5567
- reportEnd(8, "go-checkout", step8Status, Date.now() - t8);
6060
+ reportEnd(9, "go-checkout", step9Status, Date.now() - t9);
5568
6061
  return { pages, steps };
5569
6062
  } finally {
5570
6063
  await page.close();
@@ -5680,6 +6173,268 @@ async function fillCep(page, selector, cep) {
5680
6173
  return false;
5681
6174
  }
5682
6175
  }
6176
+ async function selectVariant(page, ctx) {
6177
+ const actions = [];
6178
+ let primarySelectorKey;
6179
+ let primarySelector;
6180
+ const trackPrimary = (key, sel) => {
6181
+ if (!primarySelectorKey) {
6182
+ primarySelectorKey = key;
6183
+ primarySelector = sel;
6184
+ }
6185
+ };
6186
+ try {
6187
+ const rowSelectors = selFor(ctx, "variantRow");
6188
+ const incrementSelectors = selFor(ctx, "quantityIncrement");
6189
+ rowLoop:
6190
+ for (const rowSel of rowSelectors) {
6191
+ const rows = page.locator(rowSel);
6192
+ const count = await rows.count().catch(() => 0);
6193
+ for (let i = 0;i < Math.min(count, 5); i++) {
6194
+ const row = rows.nth(i);
6195
+ if (!await row.isVisible({ timeout: 500 }).catch(() => false))
6196
+ continue;
6197
+ const rowText = (await row.innerText().catch(() => "")).replace(/\s+/g, " ").trim();
6198
+ for (const incSel of incrementSelectors) {
6199
+ const plus = row.locator(incSel).first();
6200
+ if (!await plus.isVisible({ timeout: 500 }).catch(() => false))
6201
+ continue;
6202
+ if (await plus.isDisabled().catch(() => false))
6203
+ continue;
6204
+ await plus.click({ timeout: 2000 }).catch(() => {
6205
+ return;
6206
+ });
6207
+ await page.waitForTimeout(400);
6208
+ actions.push(`Incrementou quantidade da variante \`${rowSel}\`[${i}]${rowText ? ` (${rowText.slice(0, 40)})` : ""} via \`${incSel}\``);
6209
+ trackPrimary("variantRow", rowSel);
6210
+ break rowLoop;
6211
+ }
6212
+ }
6213
+ }
6214
+ } catch {}
6215
+ if (actions.length === 0) {
6216
+ const sizeHit = await firstVisibleLocator(page, selFor(ctx, "sizeSwatch"));
6217
+ if (sizeHit && !await sizeHit.locator.isDisabled().catch(() => false)) {
6218
+ const sizeText = (await sizeHit.locator.innerText().catch(() => "")).slice(0, 20).trim();
6219
+ await sizeHit.locator.click({ timeout: 2000 }).catch(() => {
6220
+ return;
6221
+ });
6222
+ await page.waitForTimeout(400);
6223
+ actions.push(`Selecionou tamanho${sizeText ? ` '${sizeText}'` : ""} (\`${sizeHit.selector}\`)`);
6224
+ trackPrimary("sizeSwatch", sizeHit.selector);
6225
+ }
6226
+ }
6227
+ const colorHit = await firstVisibleLocator(page, selFor(ctx, "colorSwatch"));
6228
+ if (colorHit && !await colorHit.locator.isDisabled().catch(() => false)) {
6229
+ const colorText = (await colorHit.locator.innerText().catch(() => "")).slice(0, 20).trim();
6230
+ const colorLabel = colorText || await colorHit.locator.getAttribute("aria-label").catch(() => null) || "";
6231
+ await colorHit.locator.click({ timeout: 2000 }).catch(() => {
6232
+ return;
6233
+ });
6234
+ await page.waitForTimeout(400);
6235
+ actions.push(`Selecionou cor${colorLabel ? ` '${colorLabel}'` : ""} (\`${colorHit.selector}\`)`);
6236
+ trackPrimary("colorSwatch", colorHit.selector);
6237
+ }
6238
+ if (actions.length === 0) {
6239
+ await scrollPageInChunks(page);
6240
+ const result = await findAndIncrementZeroQtyInput(page, ctx);
6241
+ if (result) {
6242
+ actions.push(result.description);
6243
+ trackPrimary(result.selectorKey, result.usedSelector);
6244
+ }
6245
+ }
6246
+ let variantRequired = false;
6247
+ try {
6248
+ const bodyText = await page.locator("body").innerText({ timeout: 1000 }).catch(() => "");
6249
+ variantRequired = VARIANT_REQUIRED_TEXT_PATTERNS.some((re) => re.test(bodyText));
6250
+ } catch {}
6251
+ return { actions, primarySelectorKey, primarySelector, variantRequired };
6252
+ }
6253
+ async function scrollPageInChunks(page) {
6254
+ await page.evaluate(async () => {
6255
+ const height = document.body.scrollHeight;
6256
+ const stops = [0.2, 0.4, 0.6, 0.8, 1, 0.4];
6257
+ for (const frac of stops) {
6258
+ window.scrollTo({ top: height * frac, behavior: "instant" });
6259
+ await new Promise((r) => setTimeout(r, 300));
6260
+ }
6261
+ window.scrollTo({ top: 0, behavior: "instant" });
6262
+ }).catch(() => {
6263
+ return;
6264
+ });
6265
+ await page.waitForTimeout(1200);
6266
+ }
6267
+ async function findAndIncrementZeroQtyInput(page, ctx) {
6268
+ const qtySelectors = selFor(ctx, "quantityInput");
6269
+ const scopeSelectors = [
6270
+ "[data-manifest-key*='ProductDetails' i]",
6271
+ "[data-manifest-key*='Product/' i]",
6272
+ "main"
6273
+ ];
6274
+ const scopeRoots = [];
6275
+ for (const sel of scopeSelectors) {
6276
+ const cand = page.locator(sel).first();
6277
+ if (await cand.count().then((n) => n > 0).catch(() => false)) {
6278
+ scopeRoots.push({ root: cand, tag: "main" });
6279
+ break;
6280
+ }
6281
+ }
6282
+ scopeRoots.push({ root: null, tag: "page" });
6283
+ for (const { root, tag } of scopeRoots) {
6284
+ for (const sel of qtySelectors) {
6285
+ const all = root ? root.locator(sel) : page.locator(sel);
6286
+ const count = await all.count().catch(() => 0);
6287
+ for (let i = 0;i < Math.min(count, 20); i++) {
6288
+ const el = all.nth(i);
6289
+ if (!await el.isVisible({ timeout: 300 }).catch(() => false))
6290
+ continue;
6291
+ const value = (await el.inputValue().catch(() => "")).trim();
6292
+ if (value !== "" && value !== "0")
6293
+ continue;
6294
+ const plus = await findPlusButtonNear(el);
6295
+ if (plus) {
6296
+ if (await plus.isDisabled().catch(() => false))
6297
+ continue;
6298
+ await plus.click({ timeout: 2000 }).catch(() => {
6299
+ return;
6300
+ });
6301
+ await page.waitForTimeout(500);
6302
+ return {
6303
+ description: `Incrementou variante via botão '+' próximo ao input \`${sel}\` (scope=${tag}, índice ${i})`,
6304
+ selectorKey: "quantityInput",
6305
+ usedSelector: sel
6306
+ };
6307
+ }
6308
+ await el.fill("1", { timeout: 1500 }).catch(() => {
6309
+ return;
6310
+ });
6311
+ await el.dispatchEvent("input").catch(() => {
6312
+ return;
6313
+ });
6314
+ await el.dispatchEvent("change").catch(() => {
6315
+ return;
6316
+ });
6317
+ await page.waitForTimeout(400);
6318
+ return {
6319
+ description: `Ajustou quantidade do input \`${sel}\` para 1 (scope=${tag}, índice ${i})`,
6320
+ selectorKey: "quantityInput",
6321
+ usedSelector: sel
6322
+ };
6323
+ }
6324
+ }
6325
+ }
6326
+ return null;
6327
+ }
6328
+ async function findPlusButtonNear(input) {
6329
+ const ancestorXPath = "xpath=ancestor::*[self::li or self::tr or self::form or self::fieldset or @data-sku-row or @data-variant-row][1]//button[normalize-space(.)='+']";
6330
+ const inAncestor = input.locator(ancestorXPath).first();
6331
+ if (await inAncestor.isVisible({ timeout: 600 }).catch(() => false)) {
6332
+ return inAncestor;
6333
+ }
6334
+ for (let depth = 1;depth <= 6; depth++) {
6335
+ const climb = `xpath=ancestor::*[${depth}]//button[normalize-space(.)='+']`;
6336
+ const cand = input.locator(climb).first();
6337
+ if (await cand.isVisible({ timeout: 300 }).catch(() => false)) {
6338
+ return cand;
6339
+ }
6340
+ }
6341
+ const sibling = input.locator("xpath=following-sibling::button[1]").first();
6342
+ if (await sibling.isVisible({ timeout: 300 }).catch(() => false)) {
6343
+ const text = (await sibling.innerText().catch(() => "")).trim();
6344
+ if (text === "+")
6345
+ return sibling;
6346
+ }
6347
+ return null;
6348
+ }
6349
+ async function readCartCount(page, ctx) {
6350
+ const selectors = selFor(ctx, "minicartCount");
6351
+ for (const sel of selectors) {
6352
+ try {
6353
+ const el = page.locator(sel).first();
6354
+ if (!await el.isVisible({ timeout: 500 }).catch(() => false))
6355
+ continue;
6356
+ const raw = (await el.innerText().catch(() => "")).trim();
6357
+ const match = raw.match(/\d+/);
6358
+ if (match)
6359
+ return Number.parseInt(match[0], 10);
6360
+ } catch {}
6361
+ }
6362
+ return 0;
6363
+ }
6364
+ async function validateAddToCart(page, ctx, cartCountBefore, beforeUrl) {
6365
+ const deadline = Date.now() + 3000;
6366
+ let lastErrorText;
6367
+ while (Date.now() < deadline) {
6368
+ const currentUrl = page.url();
6369
+ if (currentUrl !== beforeUrl && /\/(cart|carrinho|checkout)(\/|$|\?)/i.test(currentUrl)) {
6370
+ return {
6371
+ status: "ok",
6372
+ signal: "url-changed",
6373
+ note: `URL mudou para \`${currentUrl}\` (carrinho/checkout)`
6374
+ };
6375
+ }
6376
+ const cartCountNow = await readCartCount(page, ctx);
6377
+ if (cartCountNow > cartCountBefore) {
6378
+ return {
6379
+ status: "ok",
6380
+ signal: "count-increased",
6381
+ note: `Contador do minicart foi de ${cartCountBefore} para ${cartCountNow}`
6382
+ };
6383
+ }
6384
+ const drawerSelectors = [
6385
+ "[role='dialog']",
6386
+ "[data-minicart][aria-expanded='true']",
6387
+ "[data-cart-drawer].open",
6388
+ "[data-minicart-drawer]:not([hidden])",
6389
+ ".minicart--open",
6390
+ ".cart-drawer--open"
6391
+ ];
6392
+ for (const sel of drawerSelectors) {
6393
+ const el = page.locator(sel).first();
6394
+ if (await el.isVisible({ timeout: 200 }).catch(() => false)) {
6395
+ return {
6396
+ status: "ok",
6397
+ signal: "drawer-open",
6398
+ note: `Drawer/modal do carrinho abriu (\`${sel}\`)`
6399
+ };
6400
+ }
6401
+ }
6402
+ try {
6403
+ const bodyText = await page.locator("body").innerText({ timeout: 500 }).catch(() => "");
6404
+ for (const re of ADD_TO_CART_SUCCESS_PATTERNS) {
6405
+ const match = bodyText.match(re);
6406
+ if (match) {
6407
+ return {
6408
+ status: "ok",
6409
+ signal: "success-toast",
6410
+ note: `Toast de sucesso visível: '${match[0]}'`
6411
+ };
6412
+ }
6413
+ }
6414
+ for (const re of ADD_TO_CART_ERROR_PATTERNS) {
6415
+ const match = bodyText.match(re);
6416
+ if (match) {
6417
+ lastErrorText = match[0];
6418
+ break;
6419
+ }
6420
+ }
6421
+ } catch {}
6422
+ await page.waitForTimeout(250);
6423
+ }
6424
+ if (lastErrorText) {
6425
+ return {
6426
+ status: "failed",
6427
+ signal: "error-text",
6428
+ note: `add-to-cart silenciosamente falhou: '${lastErrorText}' visível na página`,
6429
+ errorText: lastErrorText
6430
+ };
6431
+ }
6432
+ return {
6433
+ status: "failed",
6434
+ signal: "no-signal",
6435
+ note: "add-to-cart sem confirmação visível (minicart não atualizou, sem drawer, sem mudança de URL)"
6436
+ };
6437
+ }
5683
6438
  async function attemptRecovery(page, _ctx, stepName, intendedAction, alreadyTried) {
5684
6439
  let html = "";
5685
6440
  try {
@@ -5698,6 +6453,460 @@ async function attemptRecovery(page, _ctx, stepName, intendedAction, alreadyTrie
5698
6453
  } catch {}
5699
6454
  return null;
5700
6455
  }
6456
+ async function attemptStepAction(args) {
6457
+ const { page, ctx, stepName, intendedAction, selectorKey, action, value, recoveryBudget } = args;
6458
+ const selectors = selFor(ctx, selectorKey);
6459
+ for (const sel of selectors) {
6460
+ try {
6461
+ const el = page.locator(sel).first();
6462
+ if (!await el.isVisible({ timeout: 800 }).catch(() => false))
6463
+ continue;
6464
+ const ok = await performAction(el, action, value);
6465
+ if (ok) {
6466
+ return { performed: true, selector: sel, action, recoveredByLlm: false };
6467
+ }
6468
+ } catch {}
6469
+ }
6470
+ if (recoveryBudget.remaining <= 0) {
6471
+ return { performed: false, selector: "", action, recoveredByLlm: false };
6472
+ }
6473
+ let html = "";
6474
+ try {
6475
+ html = await page.content();
6476
+ } catch {
6477
+ return { performed: false, selector: "", action, recoveredByLlm: false };
6478
+ }
6479
+ const suggestion = await suggestRecovery({
6480
+ stepName,
6481
+ intendedAction,
6482
+ html,
6483
+ alreadyTried: selectors
6484
+ });
6485
+ if (!suggestion) {
6486
+ return { performed: false, selector: "", action, recoveredByLlm: false };
6487
+ }
6488
+ recoveryBudget.remaining--;
6489
+ const llmAction = suggestion.action ?? action;
6490
+ const llmValue = suggestion.value ?? value;
6491
+ try {
6492
+ const el = page.locator(suggestion.selector).first();
6493
+ if (!await el.isVisible({ timeout: 2000 }).catch(() => false)) {
6494
+ return { performed: false, selector: "", action: llmAction, recoveredByLlm: false };
6495
+ }
6496
+ const ok = await performAction(el, llmAction, llmValue);
6497
+ if (ok) {
6498
+ return {
6499
+ performed: true,
6500
+ selector: suggestion.selector,
6501
+ action: llmAction,
6502
+ recoveredByLlm: true
6503
+ };
6504
+ }
6505
+ } catch {}
6506
+ return { performed: false, selector: "", action: llmAction, recoveredByLlm: false };
6507
+ }
6508
+ async function performAction(el, action, value) {
6509
+ try {
6510
+ if (action === "click") {
6511
+ await el.click({ timeout: 3000 });
6512
+ return true;
6513
+ }
6514
+ if (action === "fill") {
6515
+ await el.fill(value ?? "", { timeout: 3000 });
6516
+ await el.press("Enter", { timeout: 1500 }).catch(() => {
6517
+ return;
6518
+ });
6519
+ return true;
6520
+ }
6521
+ if (action === "press") {
6522
+ await el.press(value ?? "Enter", { timeout: 1500 });
6523
+ return true;
6524
+ }
6525
+ } catch {
6526
+ return false;
6527
+ }
6528
+ return false;
6529
+ }
6530
+ var GENERIC_TITLE_PATTERNS = [
6531
+ /^p[áa]gina de produto/i,
6532
+ /^product page/i,
6533
+ /^carregando/i,
6534
+ /^loading/i,
6535
+ /^undefined/i,
6536
+ /^home$/i
6537
+ ];
6538
+ function looksGeneric(s) {
6539
+ const t = s.trim();
6540
+ return GENERIC_TITLE_PATTERNS.some((re) => re.test(t));
6541
+ }
6542
+ async function extractProductTitle(page) {
6543
+ await page.waitForSelector("h1", { timeout: 2500, state: "attached" }).catch(() => {
6544
+ return;
6545
+ });
6546
+ const visibleSelectors = [
6547
+ "main h1",
6548
+ "h1[class*='product' i]",
6549
+ "h1.product-title",
6550
+ "[itemprop='name'][data-product-name]",
6551
+ "[data-product-name]",
6552
+ "[data-fs-product-title]",
6553
+ "[itemprop='name']",
6554
+ ".vtex-store-components-3-x-productNameContainer",
6555
+ "h1"
6556
+ ];
6557
+ for (const sel of visibleSelectors) {
6558
+ try {
6559
+ const el = page.locator(sel).first();
6560
+ const visible = await withCap(el.isVisible({ timeout: 250 }).catch(() => false), 400, false);
6561
+ if (!visible)
6562
+ continue;
6563
+ const text = await withCap(el.innerText().catch(() => ""), 500, "");
6564
+ const clean = text.trim();
6565
+ if (clean.length > 3 && !looksGeneric(clean))
6566
+ return clean;
6567
+ } catch {}
6568
+ }
6569
+ try {
6570
+ const jsonLdName = await withCap(page.evaluate(() => {
6571
+ const scripts = Array.from(document.querySelectorAll('script[type="application/ld+json"]'));
6572
+ for (const s of scripts) {
6573
+ try {
6574
+ const data = JSON.parse(s.textContent ?? "{}");
6575
+ const items = Array.isArray(data) ? data : [data];
6576
+ for (const item of items) {
6577
+ const product = item?.["@graph"]?.find?.((x) => x?.["@type"] === "Product") ?? item;
6578
+ if (product?.["@type"] === "Product" && typeof product.name === "string") {
6579
+ return product.name;
6580
+ }
6581
+ }
6582
+ } catch {}
6583
+ }
6584
+ return null;
6585
+ }), 1000, null);
6586
+ if (jsonLdName && jsonLdName.trim().length > 3 && !looksGeneric(jsonLdName)) {
6587
+ return jsonLdName.trim();
6588
+ }
6589
+ } catch {}
6590
+ try {
6591
+ const og = await withCap(page.locator("meta[property='og:title']").first().getAttribute("content").catch(() => null), 500, null);
6592
+ if (og && og.trim().length > 3 && !looksGeneric(og))
6593
+ return og.trim();
6594
+ } catch {}
6595
+ const docTitle = await withCap(page.title().catch(() => ""), 500, "");
6596
+ if (docTitle.trim().length > 3 && !looksGeneric(docTitle))
6597
+ return docTitle.trim();
6598
+ return null;
6599
+ }
6600
+ async function isCartUiVisible(page) {
6601
+ return firstVisible(page, [
6602
+ "[role='dialog']:visible",
6603
+ "[aria-modal='true']:visible",
6604
+ "[data-minicart][aria-hidden='false']",
6605
+ "[data-minicart-open]",
6606
+ ".minicart--open",
6607
+ ".minicart-drawer:not([hidden])",
6608
+ "[class*='minicart'][class*='open']",
6609
+ "[class*='cart-drawer'][class*='open']",
6610
+ "[class*='drawer-cart']:visible"
6611
+ ]);
6612
+ }
6613
+ async function isCartRevealed(page, expectedProductTitle) {
6614
+ if (expectedProductTitle) {
6615
+ const v = await validateCartContainsTitleQuick(page, expectedProductTitle);
6616
+ if (v)
6617
+ return `title-found:${v}`;
6618
+ }
6619
+ return isCartUiVisible(page);
6620
+ }
6621
+ async function validateCartContainsTitleQuick(page, expectedTitle) {
6622
+ const quickSelectors = [
6623
+ "[role='dialog']",
6624
+ "[class*='minicart' i]",
6625
+ "[class*='cart' i]",
6626
+ "[class*='checkout' i]",
6627
+ "#cart-fixed",
6628
+ "table.cart-items"
6629
+ ];
6630
+ for (const scope of quickSelectors) {
6631
+ try {
6632
+ const scopeLoc = page.locator(scope);
6633
+ if (await withCap(scopeLoc.count(), 800, 0) === 0)
6634
+ continue;
6635
+ const text = await withCap(scopeLoc.first().innerText().catch(() => ""), 800, "");
6636
+ if (text && titlesMatch(text, expectedTitle))
6637
+ return scope;
6638
+ } catch {}
6639
+ }
6640
+ return null;
6641
+ }
6642
+ async function dismissOverlays(page, ctx) {
6643
+ const overlaySelectors = [
6644
+ "[class*='cookie' i][class*='banner' i]",
6645
+ "[class*='cookie' i][class*='consent' i]",
6646
+ "[id*='cookie' i][class*='banner' i]",
6647
+ "[role='alertdialog']:visible",
6648
+ "[class*='toast' i]:visible",
6649
+ "[class*='snackbar' i]:visible",
6650
+ "[class*='added-to-cart' i]:visible",
6651
+ "[class*='product-added' i]:visible"
6652
+ ];
6653
+ let dismissedAny = false;
6654
+ for (const sel of overlaySelectors) {
6655
+ try {
6656
+ const overlay = page.locator(sel).first();
6657
+ if (!await withCap(overlay.isVisible({ timeout: 200 }).catch(() => false), 400, false))
6658
+ continue;
6659
+ const closer = overlay.locator("button[aria-label*='close' i], button[aria-label*='fechar' i], button[class*='close' i], [data-close], [aria-label='Close']").first();
6660
+ if (await withCap(closer.isVisible({ timeout: 200 }).catch(() => false), 400, false)) {
6661
+ await closer.click({ timeout: 1500 }).catch(() => {
6662
+ return;
6663
+ });
6664
+ dismissedAny = true;
6665
+ continue;
6666
+ }
6667
+ await page.keyboard.press("Escape").catch(() => {
6668
+ return;
6669
+ });
6670
+ dismissedAny = true;
6671
+ } catch {}
6672
+ }
6673
+ if (dismissedAny) {
6674
+ dlog2(ctx, " openMinicart: dismissed overlay(s) before interacting");
6675
+ await page.waitForTimeout(500);
6676
+ }
6677
+ }
6678
+ async function waitForCartHydration(page) {
6679
+ await Promise.race([
6680
+ page.waitForResponse((r) => /\/api\/checkout\/pub\/orderForm|orderForm|cart\/api/i.test(r.url()) && r.ok(), { timeout: 8000 }).catch(() => {
6681
+ return;
6682
+ }),
6683
+ page.waitForSelector(".cart-items, [class*='cart-item' i], #cart-fixed .item, [data-cart-item]", { timeout: 8000 }).catch(() => {
6684
+ return;
6685
+ })
6686
+ ]);
6687
+ await page.waitForTimeout(800);
6688
+ }
6689
+ async function openMinicart(page, trigger, ctx, expectedProductTitle) {
6690
+ const beforeUrl = page.url();
6691
+ await dismissOverlays(page, ctx);
6692
+ await page.waitForTimeout(800);
6693
+ const alreadyOpen = await isCartRevealed(page, expectedProductTitle);
6694
+ if (alreadyOpen) {
6695
+ dlog2(ctx, ` openMinicart: already-open (matched ${alreadyOpen})`);
6696
+ return { method: "already-open", url: beforeUrl, visibleMarker: alreadyOpen };
6697
+ }
6698
+ const triggerHref = await trigger.locator.getAttribute("href").catch(() => null);
6699
+ const hrefHasCartTarget = !!triggerHref && /\/(checkout|cart|carrinho)/i.test(triggerHref);
6700
+ if (ctx.viewport === "desktop") {
6701
+ dlog2(ctx, ` openMinicart: trying hover first on ${trigger.selector}${triggerHref ? ` (href=${triggerHref})` : ""}`);
6702
+ await trigger.locator.hover({ timeout: 3000 }).catch(() => {
6703
+ return;
6704
+ });
6705
+ await page.waitForTimeout(1500);
6706
+ const hoverOpened = await isCartRevealed(page, expectedProductTitle);
6707
+ if (hoverOpened) {
6708
+ dlog2(ctx, ` openMinicart: hover opened drawer (${hoverOpened})`);
6709
+ return { method: "hover", url: page.url(), visibleMarker: hoverOpened };
6710
+ }
6711
+ }
6712
+ if (ctx.viewport === "mobile") {
6713
+ dlog2(ctx, ` openMinicart: trying tap (mobile) on ${trigger.selector}`);
6714
+ await Promise.all([
6715
+ page.waitForURL((url) => url.toString() !== beforeUrl, { timeout: 4000 }).catch(() => {
6716
+ return;
6717
+ }),
6718
+ trigger.locator.tap({ timeout: 4000 }).catch(() => {
6719
+ return;
6720
+ })
6721
+ ]);
6722
+ if (page.url() !== beforeUrl) {
6723
+ await page.waitForLoadState("load", { timeout: 8000 }).catch(() => {
6724
+ return;
6725
+ });
6726
+ await waitForCartHydration(page);
6727
+ dlog2(ctx, ` openMinicart: tap navigated → ${page.url()} (settled)`);
6728
+ return { method: "click-navigate", url: page.url(), visibleMarker: null };
6729
+ }
6730
+ await page.waitForTimeout(800);
6731
+ const tapOpened = await isCartRevealed(page, expectedProductTitle);
6732
+ if (tapOpened) {
6733
+ dlog2(ctx, ` openMinicart: tap opened drawer (${tapOpened})`);
6734
+ return { method: "click", url: page.url(), visibleMarker: tapOpened };
6735
+ }
6736
+ }
6737
+ dlog2(ctx, ` openMinicart: trying force-click on ${trigger.selector}${triggerHref ? ` (href=${triggerHref})` : ""}`);
6738
+ await Promise.all([
6739
+ page.waitForURL((url) => url.toString() !== beforeUrl, { timeout: 4000 }).catch(() => {
6740
+ return;
6741
+ }),
6742
+ trigger.locator.click({ force: true, timeout: 4000 }).catch(() => {
6743
+ return;
6744
+ })
6745
+ ]);
6746
+ const afterClickUrl = page.url();
6747
+ if (afterClickUrl !== beforeUrl) {
6748
+ await page.waitForLoadState("load", { timeout: 8000 }).catch(() => {
6749
+ return;
6750
+ });
6751
+ await waitForCartHydration(page);
6752
+ dlog2(ctx, ` openMinicart: click navigated → ${page.url()} (settled)`);
6753
+ return { method: "click-navigate", url: page.url(), visibleMarker: null };
6754
+ }
6755
+ await page.waitForTimeout(1500);
6756
+ const clickOpened = await isCartRevealed(page, expectedProductTitle);
6757
+ if (clickOpened) {
6758
+ dlog2(ctx, ` openMinicart: click opened drawer (${clickOpened})`);
6759
+ return { method: "click", url: afterClickUrl, visibleMarker: clickOpened };
6760
+ }
6761
+ if (ctx.viewport !== "desktop") {
6762
+ dlog2(ctx, ` openMinicart: click didn't reveal cart, trying hover (mobile)`);
6763
+ await trigger.locator.hover({ timeout: 3000 }).catch(() => {
6764
+ return;
6765
+ });
6766
+ await page.waitForTimeout(1500);
6767
+ const hoverOpened = await isCartRevealed(page, expectedProductTitle);
6768
+ if (hoverOpened) {
6769
+ dlog2(ctx, ` openMinicart: hover opened drawer (${hoverOpened})`);
6770
+ return { method: "hover", url: page.url(), visibleMarker: hoverOpened };
6771
+ }
6772
+ }
6773
+ if (hrefHasCartTarget && triggerHref) {
6774
+ const targetUrl = (() => {
6775
+ try {
6776
+ return new URL(triggerHref, page.url()).toString();
6777
+ } catch {
6778
+ return null;
6779
+ }
6780
+ })();
6781
+ if (targetUrl) {
6782
+ dlog2(ctx, ` openMinicart: all interactive strategies failed; navigating directly to ${targetUrl}`);
6783
+ await page.goto(targetUrl, { waitUntil: "load", timeout: 15000 }).catch(() => {
6784
+ return;
6785
+ });
6786
+ await waitForCartHydration(page);
6787
+ if (page.url() !== beforeUrl) {
6788
+ dlog2(ctx, ` openMinicart: goto fallback landed on ${page.url()}`);
6789
+ return { method: "click-navigate", url: page.url(), visibleMarker: null };
6790
+ }
6791
+ }
6792
+ }
6793
+ dlog2(ctx, " openMinicart: failed — no cart revealed by hover/click/goto");
6794
+ return { method: "failed", url: page.url(), visibleMarker: null };
6795
+ }
6796
+ function normalizeTitle(s) {
6797
+ return s.toLowerCase().replace(/[®©™]/g, "").replace(/[^\p{L}\p{N}\s]+/gu, " ").replace(/\s+/g, " ").trim();
6798
+ }
6799
+ function titlesMatch(observed, expected) {
6800
+ const o = normalizeTitle(observed);
6801
+ const e = normalizeTitle(expected);
6802
+ if (!o || !e)
6803
+ return false;
6804
+ if (o === e)
6805
+ return true;
6806
+ if (e.length >= 12 && o.includes(e))
6807
+ return true;
6808
+ if (o.length >= 12 && e.includes(o))
6809
+ return true;
6810
+ return false;
6811
+ }
6812
+ async function validateCartContainsTitle(page, expectedTitle, ctx) {
6813
+ const titleSelectors = [
6814
+ "[data-cart-item-name]",
6815
+ "[data-cart-item] [class*='title' i]",
6816
+ "[data-cart-item] [class*='name' i]",
6817
+ "[class*='cart' i] [data-product-name]",
6818
+ "[role='dialog'] [data-product-name]",
6819
+ "[class*='checkout' i] [data-product-name]",
6820
+ "[class*='minicart' i] [data-product-name]",
6821
+ "[data-testid='cart-item-name']",
6822
+ "[data-testid='product-name']",
6823
+ "[role='dialog'] li [class*='product' i]",
6824
+ "[role='dialog'] li [class*='name' i]",
6825
+ "[role='dialog'] li [class*='title' i]",
6826
+ "[role='dialog'] a[href*='/p']",
6827
+ "[class*='minicart' i] [class*='item' i] [class*='name' i]",
6828
+ "[class*='minicart' i] [class*='item' i] [class*='title' i]",
6829
+ "[class*='cart-item' i] [class*='name' i]",
6830
+ "[class*='cart-item' i] [class*='title' i]",
6831
+ "[class*='checkout' i] [class*='product' i] [class*='name' i]",
6832
+ ".vtex-minicart-2-x-itemNameContainer",
6833
+ ".vtex-checkout-summary-0-x-itemName",
6834
+ ".product-name",
6835
+ ".item-name",
6836
+ "a.product-name",
6837
+ ".cart-items .item-name",
6838
+ "tr.product-item .item-name",
6839
+ "tr.cart-item .item-name",
6840
+ "table.cart-items td a",
6841
+ "#cart-fixed .item .product-name",
6842
+ "#cart-fixed .item-name",
6843
+ ".cart-fixed .item-name",
6844
+ ".cart-fixed .product-name",
6845
+ "#cart-fixed li a",
6846
+ "#minicart-content .item-name",
6847
+ "[data-fs-cart-item-summary-title]",
6848
+ "[data-fs-cart-item-image] + * a",
6849
+ "[class*='cart' i] a[href*='/p']",
6850
+ "[class*='checkout' i] a[href*='/p']"
6851
+ ];
6852
+ const sweepTitles = async () => {
6853
+ const observed2 = [];
6854
+ for (const sel of titleSelectors) {
6855
+ try {
6856
+ const loc = page.locator(sel);
6857
+ const count = await withCap(loc.count(), 1000, 0);
6858
+ const limit = Math.min(count, 10);
6859
+ for (let i = 0;i < limit; i++) {
6860
+ const el = loc.nth(i);
6861
+ const visible = await withCap(el.isVisible({ timeout: 200 }).catch(() => false), 400, false);
6862
+ if (!visible)
6863
+ continue;
6864
+ const text = await withCap(el.innerText().catch(() => ""), 500, "");
6865
+ const clean = text.trim().slice(0, 200);
6866
+ if (clean.length > 2)
6867
+ observed2.push(clean);
6868
+ }
6869
+ if (observed2.length > 0)
6870
+ return observed2;
6871
+ } catch {}
6872
+ }
6873
+ return observed2;
6874
+ };
6875
+ let observed = await sweepTitles();
6876
+ if (observed.length === 0) {
6877
+ dlog2(ctx, " validateCartContainsTitle: 0 titles on first pass, retrying after 2s");
6878
+ await page.waitForTimeout(2000);
6879
+ observed = await sweepTitles();
6880
+ }
6881
+ dlog2(ctx, ` validateCartContainsTitle: observed ${observed.length} titles`);
6882
+ if (observed.length === 0) {
6883
+ return { found: false, observedTitles: [], method: "none" };
6884
+ }
6885
+ const found = observed.some((o) => titlesMatch(o, expectedTitle));
6886
+ return { found, observedTitles: observed, method: "selector" };
6887
+ }
6888
+ async function detectEmptyCartBanner(page) {
6889
+ const bannerSelectors = [
6890
+ ":text-matches('carrinho.*vazio', 'i')",
6891
+ ":text-matches('seu carrinho está vazio', 'i')",
6892
+ ":text-matches('empty cart', 'i')",
6893
+ ":text-matches('cart is empty', 'i')",
6894
+ ":text-matches('nenhum item.*carrinho', 'i')",
6895
+ "[class*='empty' i][class*='cart' i]",
6896
+ "[class*='cart-empty' i]"
6897
+ ];
6898
+ for (const sel of bannerSelectors) {
6899
+ try {
6900
+ const loc = page.locator(sel).first();
6901
+ if (await withCap(loc.isVisible({ timeout: 300 }).catch(() => false), 500, false)) {
6902
+ const text = await withCap(loc.innerText().catch(() => ""), 500, "");
6903
+ if (text.trim())
6904
+ return text.trim().slice(0, 120);
6905
+ }
6906
+ } catch {}
6907
+ }
6908
+ return null;
6909
+ }
5701
6910
 
5702
6911
  // src/ignore/parser.ts
5703
6912
  import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
@@ -5807,6 +7016,51 @@ function detectFromHtml(html) {
5807
7016
  return "custom";
5808
7017
  }
5809
7018
 
7019
+ // src/learned/promote.ts
7020
+ function promoteStepsFromFlow(learned, platform, host, flow) {
7021
+ let promoted = 0;
7022
+ let deprecated = 0;
7023
+ let recorded = 0;
7024
+ for (const step of flow.steps ?? []) {
7025
+ if (!step.selectorKey || !step.usedSelector)
7026
+ continue;
7027
+ if (!isReusableSelector(step.usedSelector))
7028
+ continue;
7029
+ const parsed = SelectorKey.safeParse(step.selectorKey);
7030
+ if (!parsed.success)
7031
+ continue;
7032
+ const key = parsed.data;
7033
+ if (step.recoveredByLlm) {
7034
+ promoteFromLlm(learned, platform, key, step.usedSelector, host);
7035
+ promoted++;
7036
+ } else if (step.status === "ok") {
7037
+ recordSuccess(learned, platform, key, step.usedSelector, host);
7038
+ recorded++;
7039
+ } else if (step.status === "failed") {
7040
+ const wasDeprecated = isAlreadyDeprecated(learned, platform, key, step.usedSelector);
7041
+ const after = recordFailure(learned, platform, key, step.usedSelector, host);
7042
+ if (!wasDeprecated && after?.deprecated)
7043
+ deprecated++;
7044
+ }
7045
+ }
7046
+ return { promoted, deprecated, recorded };
7047
+ }
7048
+ function isReusableSelector(s) {
7049
+ if (s.includes("→"))
7050
+ return false;
7051
+ if (s.includes(" ~ "))
7052
+ return false;
7053
+ return s.trim().length > 0;
7054
+ }
7055
+ function isAlreadyDeprecated(lib, platform, key, selector) {
7056
+ const platformEntries = lib.platforms[platform];
7057
+ if (!platformEntries)
7058
+ return false;
7059
+ const entries = platformEntries[key] ?? [];
7060
+ const entry = entries.find((e) => e.selector === selector);
7061
+ return !!entry?.deprecated;
7062
+ }
7063
+
5810
7064
  // src/llm/discover-selectors.ts
5811
7065
  import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "node:fs";
5812
7066
  import { join as join6 } from "node:path";
@@ -5832,26 +7086,26 @@ var DISCOVER_SELECTORS_TOOL = {
5832
7086
  },
5833
7087
  minicart_trigger: {
5834
7088
  type: "string",
5835
- description: "CSS selector for the element that opens the mini-cart drawer/popup (e.g. '[data-minicart-trigger]', or a header cart icon)."
7089
+ description: `CSS selector for the CART ICON / SACOLA icon in the HEADER that opens the mini-cart drawer or navigates to the cart page when clicked (e.g. '[data-minicart-trigger]', or 'header a[aria-label*="sacola" i]', or 'header a[href*="cart"]'). This is the icon ALWAYS visible in the page header.`
5836
7090
  },
5837
7091
  cep_input_pdp: {
5838
7092
  type: "string",
5839
- description: "CSS selector for the CEP / zipcode <input> on a PDP. Empty string if the PDP has no shipping calculator."
7093
+ description: "CSS selector for the CEP / zipcode <input> on a PDP — the input where the customer types their ADDRESS POSTAL CODE to calculate shipping. Placeholder is typically 'CEP', 'Digite seu CEP', 'Cód. postal'. Do NOT return a coupon input ('Digite o cupom') or email/newsletter field. Empty string if the PDP has no shipping calculator."
5840
7094
  },
5841
7095
  cep_input_cart: {
5842
7096
  type: "string",
5843
- description: "CSS selector for the CEP / zipcode <input> inside the mini-cart/cart drawer. Empty string if cart has no shipping calculator."
7097
+ description: "CSS selector for the CEP / zipcode <input> inside the mini-cart drawer or cart page — for shipping calculation. Same rules: ADDRESS postal code, NOT coupon, NOT email. Empty string if cart has no shipping calculator."
5844
7098
  },
5845
7099
  checkout_button: {
5846
7100
  type: "string",
5847
- description: `CSS selector for the 'Go to checkout' / 'Finalizar compra' button inside the mini-cart/cart (e.g. "a:has-text('Finalizar compra')").`
7101
+ description: "CSS selector for the FINAL 'Go to checkout' / 'Finalizar compra' / 'Finalizar' button INSIDE the mini-cart drawer or cart page — the one user clicks AFTER reviewing cart items to proceed to checkout/payment. **IMPORTANT**: this is DIFFERENT from `minicart_trigger` (which is the cart ICON in the header). The checkout button is typically a big colored button at the bottom of the cart drawer or cart page. If you cannot see it on the home page (which is common — it's only rendered after add-to-cart), return EMPTY STRING. NEVER return the same selector as `minicart_trigger`."
5848
7102
  },
5849
7103
  reasoning: {
5850
7104
  type: "string",
5851
7105
  description: "1-2 sentence rationale describing what was inferred from the markup."
5852
7106
  }
5853
7107
  },
5854
- required: ["category_link", "product_card", "buy_button", "minicart_trigger", "checkout_button"]
7108
+ required: ["category_link", "product_card", "buy_button", "minicart_trigger"]
5855
7109
  }
5856
7110
  };
5857
7111
  var SYSTEM_PROMPT = `
@@ -5888,7 +7142,17 @@ REGRAS:
5888
7142
  4. Para "cep_input_pdp" e "cep_input_cart": retorne string vazia se não existir esse recurso na plataforma.
5889
7143
  Não invente.
5890
7144
 
5891
- 5. Sempre teste mentalmente: "este seletor pegaria o elemento certo na home E também em PDPs/cart?"
7145
+ 5. **NUNCA confunda \`minicart_trigger\` (ícone do carrinho no HEADER) com \`checkout_button\`
7146
+ (botão "Finalizar" DENTRO do drawer ou na página de cart).** Se você só vê o ícone do
7147
+ carrinho na home e não consegue ver o botão de checkout (caso comum — ele só renderiza
7148
+ depois de add-to-cart), retorne STRING VAZIA pra \`checkout_button\`. NUNCA retorne o
7149
+ mesmo seletor para os dois — isso quebra o fluxo de teste.
7150
+
7151
+ 6. Para CEP: \`cep_input_pdp\` e \`cep_input_cart\` são pra ENDEREÇO (CEP / Postal Code).
7152
+ NÃO confunda com input de CUPOM ('Digite o cupom', 'Insira código', 'Promo code') —
7153
+ cupom é desconto, não frete.
7154
+
7155
+ 7. Sempre teste mentalmente: "este seletor pegaria o elemento certo na home E também em PDPs/cart?"
5892
7156
 
5893
7157
  Responda SEMPRE via tool_use report_selectors. Não escreva texto livre fora da tool call.
5894
7158
  `.trim();
@@ -5962,12 +7226,24 @@ ${compacted}
5962
7226
  cepInputCart: emptyToUndef(input.cep_input_cart),
5963
7227
  checkoutButton: emptyToUndef(input.checkout_button)
5964
7228
  };
7229
+ if (selectors.checkoutButton && selectors.minicartTrigger && selectorsLikelyConflict(selectors.checkoutButton, selectors.minicartTrigger)) {
7230
+ console.warn(`[discover-selectors] checkout_button == minicart_trigger (\`${selectors.checkoutButton}\`); dropping to avoid step-9 misclick`);
7231
+ selectors.checkoutButton = undefined;
7232
+ }
5965
7233
  if (!existsSync6(cacheDir))
5966
7234
  mkdirSync4(cacheDir, { recursive: true });
5967
7235
  writeFileSync5(cachePath, `${JSON.stringify(selectors, null, 2)}
5968
7236
  `, "utf8");
5969
7237
  return selectors;
5970
7238
  }
7239
+ function selectorsLikelyConflict(a, b) {
7240
+ if (a === b)
7241
+ return true;
7242
+ const norm = (s) => s.replace(/\s+/g, "").toLowerCase();
7243
+ const na = norm(a);
7244
+ const nb = norm(b);
7245
+ return na.includes(nb) || nb.includes(na);
7246
+ }
5971
7247
  function emptyToUndef(v) {
5972
7248
  if (v == null)
5973
7249
  return;
@@ -5987,11 +7263,12 @@ var STEP_LABELS = {
5987
7263
  "visit-home": "1. visit-home",
5988
7264
  "navigate-plp": "2. navigate-plp",
5989
7265
  "enter-pdp": "3. enter-pdp",
5990
- "shipping-calc-pdp": "4. shipping-calc-pdp",
5991
- "add-to-cart": "5. add-to-cart",
5992
- "open-minicart": "6. open-minicart",
5993
- "shipping-calc-cart": "7. shipping-calc-cart",
5994
- "go-checkout": "8. go-checkout"
7266
+ "select-variant": "4. select-variant",
7267
+ "shipping-calc-pdp": "5. shipping-calc-pdp",
7268
+ "add-to-cart": "6. add-to-cart",
7269
+ "open-minicart": "7. open-minicart",
7270
+ "shipping-calc-cart": "8. shipping-calc-cart",
7271
+ "go-checkout": "9. go-checkout"
5995
7272
  };
5996
7273
  var CRITICAL_STEPS = new Set([
5997
7274
  "visit-home",
@@ -6120,6 +7397,39 @@ async function journeyCommand(opts) {
6120
7397
  }
6121
7398
  if (!opts.json)
6122
7399
  console.log("");
7400
+ const learnedBefore = JSON.stringify(learned);
7401
+ const prodHost = safeHost2(opts.prod);
7402
+ let totalPromoted = 0;
7403
+ let totalDeprecated = 0;
7404
+ let totalRecorded = 0;
7405
+ for (const cap of flowCaptures) {
7406
+ if (cap.side !== "prod")
7407
+ continue;
7408
+ const result = promoteStepsFromFlow(learned, platform, prodHost, cap);
7409
+ totalPromoted += result.promoted;
7410
+ totalDeprecated += result.deprecated;
7411
+ totalRecorded += result.recorded;
7412
+ }
7413
+ if (JSON.stringify(learned) !== learnedBefore) {
7414
+ try {
7415
+ saveLearned(learned);
7416
+ if (!opts.json) {
7417
+ const bits = [];
7418
+ if (totalPromoted > 0)
7419
+ bits.push(`${totalPromoted} promovido(s) via LLM`);
7420
+ if (totalRecorded > 0)
7421
+ bits.push(`${totalRecorded} reforçado(s)`);
7422
+ if (totalDeprecated > 0)
7423
+ bits.push(`${totalDeprecated} depreciado(s)`);
7424
+ if (bits.length > 0)
7425
+ console.log(chalk6.dim(` learned-selectors atualizado: ${bits.join(", ")}`));
7426
+ }
7427
+ } catch (err) {
7428
+ if (!opts.json) {
7429
+ console.warn(chalk6.yellow(` warn: failed to persist learned-selectors: ${err.message}`));
7430
+ }
7431
+ }
7432
+ }
6123
7433
  const rows = buildRows(flowCaptures, viewports);
6124
7434
  const failures = collectFailures(rows);
6125
7435
  writeRunReportJson(paths.runDir, buildJourneyRun(opts, runId, flowCaptures, failures));
@@ -6477,6 +7787,13 @@ function renderSummaryBlock(rows, failures) {
6477
7787
  function escapeHtml(s) {
6478
7788
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
6479
7789
  }
7790
+ function safeHost2(url) {
7791
+ try {
7792
+ return new URL(url).hostname;
7793
+ } catch {
7794
+ return url;
7795
+ }
7796
+ }
6480
7797
 
6481
7798
  // src/commands/learned.ts
6482
7799
  import chalk7 from "chalk";
@@ -7795,6 +9112,7 @@ var STEP_LABELS2 = {
7795
9112
  "visit-home": "Visitar home",
7796
9113
  "navigate-plp": "Navegar para categoria (PLP)",
7797
9114
  "enter-pdp": "Entrar em PDP",
9115
+ "select-variant": "Selecionar variante (tamanho/cor/quantidade)",
7798
9116
  "shipping-calc-pdp": "Cálculo de frete na PDP",
7799
9117
  "add-to-cart": "Adicionar ao carrinho",
7800
9118
  "open-minicart": "Abrir minicart",
@@ -9689,21 +11007,9 @@ async function runCommand(rawOpts) {
9689
11007
  for (const p of cap.pages)
9690
11008
  allPageCaptures.push(p);
9691
11009
  if (opts.learn !== false) {
9692
- for (const step of cap.steps ?? []) {
9693
- if (!step.selectorKey || !step.usedSelector)
9694
- continue;
9695
- const key = step.selectorKey;
9696
- if (step.recoveredByLlm) {
9697
- promoteFromLlm(learned, platform, key, step.usedSelector, prodHost);
9698
- promotedCount++;
9699
- } else if (step.status === "ok") {
9700
- recordSuccess(learned, platform, key, step.usedSelector, prodHost);
9701
- } else if (step.status === "failed") {
9702
- const before = recordFailure(learned, platform, key, step.usedSelector, prodHost);
9703
- if (before?.deprecated)
9704
- deprecatedCount++;
9705
- }
9706
- }
11010
+ const result = promoteStepsFromFlow(learned, platform, prodHost, cap);
11011
+ promotedCount += result.promoted;
11012
+ deprecatedCount += result.deprecated;
9707
11013
  }
9708
11014
  }
9709
11015
  await stopTracing(ctx, tracePath).catch(() => {