@decocms/parity 0.4.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 +25 -0
  2. package/dist/cli.js +972 -360
  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
@@ -302,7 +286,13 @@ var ParityRc = z.object({
302
286
  minicartTrigger: z.string().optional(),
303
287
  cepInputPdp: z.string().optional(),
304
288
  cepInputCart: z.string().optional(),
305
- 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()
306
296
  }).default({}),
307
297
  skipSteps: z.array(z.string()).default([])
308
298
  });
@@ -4469,6 +4459,23 @@ async function callAnthropicTool(params) {
4469
4459
  return null;
4470
4460
  }
4471
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) {
4472
4479
  const apiKey = process.env.OPENROUTER_API_KEY;
4473
4480
  if (!apiKey)
4474
4481
  return null;
@@ -4481,8 +4488,7 @@ async function callOpenRouterTool(params) {
4481
4488
  }
4482
4489
  });
4483
4490
  }
4484
- const timeoutMs = params.timeoutMs ?? defaultTimeout(params);
4485
- const { signal, clear } = makeTimeoutSignal(timeoutMs);
4491
+ const { signal, clear } = makeTimeoutSignal(attemptTimeoutMs);
4486
4492
  try {
4487
4493
  const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
4488
4494
  method: "POST",
@@ -4495,7 +4501,7 @@ async function callOpenRouterTool(params) {
4495
4501
  },
4496
4502
  body: JSON.stringify({
4497
4503
  model: LLM_MODEL_OPENROUTER,
4498
- max_tokens: params.maxTokens ?? 2000,
4504
+ max_tokens: maxTokens,
4499
4505
  messages: [
4500
4506
  { role: "system", content: params.systemPrompt },
4501
4507
  { role: "user", content: userContent }
@@ -4516,6 +4522,8 @@ async function callOpenRouterTool(params) {
4516
4522
  if (!response.ok) {
4517
4523
  const txt = await response.text().catch(() => "");
4518
4524
  console.error(`[llm-openrouter] HTTP ${response.status}: ${txt.slice(0, 200)}`);
4525
+ if (response.status >= 500 || response.status === 429)
4526
+ return;
4519
4527
  return null;
4520
4528
  }
4521
4529
  const json = await response.json();
@@ -4531,8 +4539,12 @@ async function callOpenRouterTool(params) {
4531
4539
  return JSON.parse(repaired);
4532
4540
  } catch {}
4533
4541
  }
4534
- console.error(`[llm-openrouter] failed to parse tool arguments (raw len=${raw.length}, head=${JSON.stringify(raw.slice(0, 80))})`);
4535
- return null;
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;
4536
4548
  }
4537
4549
  }
4538
4550
  const content = json.choices?.[0]?.message?.content;
@@ -4541,12 +4553,17 @@ async function callOpenRouterTool(params) {
4541
4553
  return JSON.parse(content);
4542
4554
  } catch {}
4543
4555
  }
4556
+ return null;
4544
4557
  } catch (err) {
4545
- 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;
4546
4564
  } finally {
4547
4565
  clear();
4548
4566
  }
4549
- return null;
4550
4567
  }
4551
4568
  function tryRepairJson(raw) {
4552
4569
  let s = raw.trim();
@@ -4962,7 +4979,13 @@ var SelectorKey = z2.enum([
4962
4979
  "minicartTrigger",
4963
4980
  "cepInputPdp",
4964
4981
  "cepInputCart",
4965
- "checkoutButton"
4982
+ "checkoutButton",
4983
+ "sizeSwatch",
4984
+ "colorSwatch",
4985
+ "variantRow",
4986
+ "quantityIncrement",
4987
+ "quantityInput",
4988
+ "minicartCount"
4966
4989
  ]);
4967
4990
  var SelectorEntry = z2.object({
4968
4991
  selector: z2.string(),
@@ -5156,8 +5179,80 @@ var DEFAULT_SELECTORS = {
5156
5179
  "button:has-text('Finalizar compra')",
5157
5180
  "a:has-text('Ir para o checkout')",
5158
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')",
5159
5188
  "a:has-text('Checkout')",
5160
- "[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']"
5161
5256
  ]
5162
5257
  };
5163
5258
  function selectorsFor(key, ctxOrRc = {}) {
@@ -5182,7 +5277,7 @@ function isResolutionContext(v) {
5182
5277
  }
5183
5278
 
5184
5279
  // src/engine/flows.ts
5185
- var PURCHASE_JOURNEY_TOTAL_STEPS = 8;
5280
+ var PURCHASE_JOURNEY_TOTAL_STEPS = 9;
5186
5281
  var DEBUG_PARITY2 = process.env.DEBUG_PARITY === "1" || process.env.DEBUG_PARITY === "true";
5187
5282
  var DEBUG_START2 = Date.now();
5188
5283
  function dlog2(ctx, msg) {
@@ -5192,6 +5287,41 @@ function dlog2(ctx, msg) {
5192
5287
  process.stderr.write(`[+${elapsed}s ${ctx.viewport}/${ctx.side}] ${msg}
5193
5288
  `);
5194
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
+ ];
5195
5325
  function selFor(ctx, key) {
5196
5326
  return selectorsFor(key, { rc: ctx.rc, learned: ctx.learned, platform: ctx.platform });
5197
5327
  }
@@ -5199,7 +5329,7 @@ var FLOW_DEADLINE_MS = {
5199
5329
  homepage: 90000,
5200
5330
  plp: 180000,
5201
5331
  pdp: 240000,
5202
- "purchase-journey": 360000
5332
+ "purchase-journey": 420000
5203
5333
  };
5204
5334
  async function runFlow(flow, ctx) {
5205
5335
  const start = Date.now();
@@ -5236,7 +5366,7 @@ async function runFlow(flow, ctx) {
5236
5366
  status: "failed",
5237
5367
  durationMs: deadlineMs,
5238
5368
  screenshotPath: "",
5239
- actionDescription: `[flow-timeout] flow "${flow}" excedeu ${deadlineMs}ms — captura abortada pela safety net externa, ${pages.length} page(s) fechada(s) para liberar o contexto. Step interno provavelmente travou em uma operação Playwright que não respeitou seu timeout declarado.`
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.`
5240
5370
  }
5241
5371
  ], start));
5242
5372
  const CLOSE_CAP_MS = 5000;
@@ -5244,7 +5374,7 @@ async function runFlow(flow, ctx) {
5244
5374
  p.close().catch(() => {
5245
5375
  return;
5246
5376
  }),
5247
- new Promise((resolve2) => setTimeout(resolve2, CLOSE_CAP_MS))
5377
+ new Promise((resolveClose) => setTimeout(resolveClose, CLOSE_CAP_MS))
5248
5378
  ]);
5249
5379
  cleanup = Promise.allSettled(pages.map(cappedClose));
5250
5380
  }, deadlineMs);
@@ -5352,7 +5482,7 @@ async function flowPurchaseJourney(ctx) {
5352
5482
  const pages = [];
5353
5483
  const steps = [];
5354
5484
  const page = await ctx.ctx.newPage();
5355
- let recoveryBudget = ctx.recoveryBudget ?? 3;
5485
+ const budget = { remaining: ctx.recoveryBudget ?? 5 };
5356
5486
  const total = PURCHASE_JOURNEY_TOTAL_STEPS;
5357
5487
  const reportStart = (idx, name) => {
5358
5488
  ctx.onStep?.({ phase: "start", name, index: idx, total });
@@ -5362,15 +5492,12 @@ async function flowPurchaseJourney(ctx) {
5362
5492
  };
5363
5493
  try {
5364
5494
  reportStart(1, "visit-home");
5365
- dlog2(ctx, `step 1 visit-home: capturePage(${ctx.baseUrl}) start (scrollToLoad=false)`);
5366
5495
  const homeCap = await capturePage(page, {
5367
5496
  url: ctx.baseUrl,
5368
5497
  side: ctx.side,
5369
5498
  viewport: ctx.viewport,
5370
- screenshotPath: screenshotPath(ctx, "pj-1-home"),
5371
- scrollToLoad: false
5499
+ screenshotPath: screenshotPath(ctx, "pj-1-home")
5372
5500
  });
5373
- dlog2(ctx, `step 1 visit-home: capturePage done status=${homeCap.status}`);
5374
5501
  pages.push(homeCap);
5375
5502
  const step1Status = homeCap.status >= 200 && homeCap.status < 400 ? "ok" : "failed";
5376
5503
  steps.push({
@@ -5389,24 +5516,19 @@ async function flowPurchaseJourney(ctx) {
5389
5516
  return { pages, steps };
5390
5517
  }
5391
5518
  reportStart(2, "navigate-plp");
5392
- dlog2(ctx, `step 2 navigate-plp: findCategoryUrl start (hint=${ctx.rc.plpUrlHint ?? "—"})`);
5393
5519
  const plpHit = ctx.rc.plpUrlHint ? { url: new URL(ctx.rc.plpUrlHint, ctx.baseUrl).toString(), selector: "__hint__" } : await findCategoryUrl(page, ctx);
5394
- dlog2(ctx, `step 2 navigate-plp: findCategoryUrl done → ${plpHit?.url ?? "null"}`);
5395
5520
  if (!plpHit) {
5396
5521
  steps.push(makeSkipStep(2, "navigate-plp", ctx, "no category link found"));
5397
5522
  reportEnd(2, "navigate-plp", "skipped", 0, "no category link found");
5398
5523
  return { pages, steps };
5399
5524
  }
5400
5525
  const t2 = Date.now();
5401
- dlog2(ctx, `step 2 navigate-plp: capturePage(${plpHit.url}) start (scrollToLoad=false)`);
5402
5526
  const plpCap = await capturePage(page, {
5403
5527
  url: plpHit.url,
5404
5528
  side: ctx.side,
5405
5529
  viewport: ctx.viewport,
5406
- screenshotPath: screenshotPath(ctx, "pj-2-plp"),
5407
- scrollToLoad: false
5530
+ screenshotPath: screenshotPath(ctx, "pj-2-plp")
5408
5531
  });
5409
- dlog2(ctx, `step 2 navigate-plp: capturePage done status=${plpCap.status}`);
5410
5532
  pages.push(plpCap);
5411
5533
  const step2Status = plpCap.status >= 200 && plpCap.status < 400 ? "ok" : "failed";
5412
5534
  steps.push({
@@ -5425,9 +5547,35 @@ async function flowPurchaseJourney(ctx) {
5425
5547
  steps[steps.length - 1].actionDescription = `Navegou pra categoria \`${plpHit.url}\` (via \`${plpHit.selector}\`)`;
5426
5548
  steps[steps.length - 1].beforeUrl = ctx.baseUrl;
5427
5549
  reportStart(3, "enter-pdp");
5428
- 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
+ }
5429
5577
  if (!pdpHit) {
5430
- 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)"));
5431
5579
  reportEnd(3, "enter-pdp", "skipped", 0, "no product card found");
5432
5580
  return { pages, steps };
5433
5581
  }
@@ -5436,8 +5584,7 @@ async function flowPurchaseJourney(ctx) {
5436
5584
  url: pdpHit.url,
5437
5585
  side: ctx.side,
5438
5586
  viewport: ctx.viewport,
5439
- screenshotPath: screenshotPath(ctx, "pj-3-pdp"),
5440
- scrollToLoad: false
5587
+ screenshotPath: screenshotPath(ctx, "pj-3-pdp")
5441
5588
  });
5442
5589
  pages.push(pdpCap);
5443
5590
  const step3Status = pdpCap.status >= 200 && pdpCap.status < 400 ? "ok" : "failed";
@@ -5451,10 +5598,11 @@ async function flowPurchaseJourney(ctx) {
5451
5598
  url: pdpCap.finalUrl,
5452
5599
  screenshotPath: pdpCap.screenshotPath,
5453
5600
  selectorKey: "productCard",
5454
- usedSelector: pdpHit.selector
5601
+ usedSelector: pdpHit.selector,
5602
+ recoveredByLlm: pdpRecoveredByLlm || undefined
5455
5603
  });
5456
5604
  reportEnd(3, "enter-pdp", step3Status, Date.now() - t3);
5457
- 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" : ""})`;
5458
5606
  steps[steps.length - 1].beforeUrl = plpHit.url;
5459
5607
  const expectedProductTitle = await extractProductTitle(page);
5460
5608
  dlog2(ctx, `step 3 enter-pdp: extracted product title → ${expectedProductTitle ? `"${expectedProductTitle.slice(0, 60)}"` : "null"}`);
@@ -5464,115 +5612,211 @@ async function flowPurchaseJourney(ctx) {
5464
5612
  productTitle: expectedProductTitle
5465
5613
  };
5466
5614
  }
5467
- reportStart(4, "shipping-calc-pdp");
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");
5468
5666
  let cepInputPdp = await firstVisible(page, selFor(ctx, "cepInputPdp"));
5469
- let cepPdpRecovered = false;
5470
- if (!cepInputPdp && recoveryBudget > 0) {
5471
- const recovery = await attemptRecovery(page, ctx, "shipping-calc-pdp", "Achar o input de CEP / código postal nesta PDP (deve ser um input visível com label/placeholder relacionado a frete, entrega ou CEP)", selFor(ctx, "cepInputPdp"));
5472
- if (recovery) {
5473
- cepInputPdp = recovery.selector;
5474
- cepPdpRecovered = true;
5475
- recoveryBudget--;
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
+ }
5476
5685
  }
5477
5686
  }
5478
5687
  if (cepInputPdp) {
5479
- const t4 = Date.now();
5480
- const beforeUrl4 = page.url();
5481
- const spBefore4 = screenshotPath(ctx, "pj-4-shipping-pdp-before");
5482
- 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(() => {
5483
5692
  return;
5484
5693
  });
5485
5694
  const ok = await fillCep(page, cepInputPdp, ctx.rc.cep);
5486
- const sp = screenshotPath(ctx, "pj-4-shipping-pdp");
5695
+ const sp = screenshotPath(ctx, "pj-5-shipping-pdp");
5487
5696
  await page.screenshot({ path: sp, fullPage: false }).catch(() => {
5488
5697
  return;
5489
5698
  });
5490
- const step4Status = ok ? "ok" : "failed";
5699
+ const step5Status = ok ? "ok" : "failed";
5491
5700
  steps.push({
5492
- step: 4,
5701
+ step: 5,
5493
5702
  name: "shipping-calc-pdp",
5494
5703
  side: ctx.side,
5495
5704
  viewport: ctx.viewport,
5496
- status: step4Status,
5497
- durationMs: Date.now() - t4,
5705
+ status: step5Status,
5706
+ durationMs: Date.now() - t5,
5498
5707
  url: page.url(),
5499
5708
  screenshotPath: sp,
5500
- screenshotBeforePath: spBefore4,
5501
- beforeUrl: beforeUrl4,
5502
- 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)" : ""}`,
5503
5712
  detail: { cepUsed: ctx.rc.cep },
5504
5713
  selectorKey: "cepInputPdp",
5505
5714
  usedSelector: cepInputPdp,
5506
- recoveredByLlm: cepPdpRecovered || undefined
5715
+ recoveredByLlm: cepPdpRecoveredByLlm || undefined
5507
5716
  });
5508
- reportEnd(4, "shipping-calc-pdp", step4Status, Date.now() - t4);
5717
+ reportEnd(5, "shipping-calc-pdp", step5Status, Date.now() - t5);
5509
5718
  } else {
5510
- steps.push(makeSkipStep(4, "shipping-calc-pdp", ctx, "no CEP input on PDP (recovery exhausted)"));
5511
- 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");
5512
5721
  }
5513
- reportStart(5, "add-to-cart");
5722
+ reportStart(6, "add-to-cart");
5514
5723
  let buyHit = await firstVisibleLocator(page, selFor(ctx, "buyButton"));
5515
5724
  let buyRecovered = false;
5516
- if (!buyHit && recoveryBudget > 0) {
5725
+ if (!buyHit && budget.remaining > 0) {
5517
5726
  const recovery = await attemptRecovery(page, ctx, "add-to-cart", "Clicar no botão de comprar/adicionar ao carrinho", selFor(ctx, "buyButton"));
5518
5727
  if (recovery) {
5519
5728
  buyHit = recovery;
5520
5729
  buyRecovered = true;
5521
- recoveryBudget--;
5730
+ budget.remaining--;
5522
5731
  }
5523
5732
  }
5524
5733
  if (!buyHit) {
5525
- steps.push(makeSkipStep(5, "add-to-cart", ctx, "no buy button found (recovery exhausted)"));
5526
- 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");
5527
5736
  return { pages, steps };
5528
5737
  }
5529
- const t5 = Date.now();
5530
- const beforeUrl5 = page.url();
5531
- const spBefore5 = screenshotPath(ctx, "pj-5-add-cart-before");
5532
- 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(() => {
5533
5742
  return;
5534
5743
  });
5744
+ const cartCountBefore = await readCartCount(page, ctx);
5535
5745
  const buyText = await buyHit.locator.innerText().catch(() => "");
5536
5746
  await buyHit.locator.click({ timeout: 5000 }).catch(() => {
5537
5747
  return;
5538
5748
  });
5539
- await page.waitForTimeout(2000);
5540
- const sp5 = screenshotPath(ctx, "pj-5-add-cart");
5541
- 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(() => {
5542
5779
  return;
5543
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}`;
5544
5783
  steps.push({
5545
- step: 5,
5784
+ step: 6,
5546
5785
  name: "add-to-cart",
5547
5786
  side: ctx.side,
5548
5787
  viewport: ctx.viewport,
5549
- status: "ok",
5550
- durationMs: Date.now() - t5,
5788
+ status: validation.status,
5789
+ durationMs: Date.now() - t6,
5551
5790
  url: page.url(),
5552
- screenshotPath: sp5,
5553
- screenshotBeforePath: spBefore5,
5554
- beforeUrl: beforeUrl5,
5555
- 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,
5556
5795
  selectorKey: "buyButton",
5557
5796
  usedSelector: buyHit.selector,
5558
- 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 }
5559
5800
  });
5560
- reportEnd(5, "add-to-cart", "ok", Date.now() - t5);
5561
- reportStart(6, "open-minicart");
5562
- const t6 = Date.now();
5563
- const beforeUrl6 = page.url();
5564
- const spBefore6 = screenshotPath(ctx, "pj-6-minicart-before");
5565
- 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(() => {
5566
5810
  return;
5567
5811
  });
5568
5812
  let miniHit = await firstVisibleLocator(page, selFor(ctx, "minicartTrigger"));
5569
5813
  let miniRecovered = false;
5570
- if (!miniHit && recoveryBudget > 0) {
5814
+ if (!miniHit && budget.remaining > 0) {
5571
5815
  const recovery = await attemptRecovery(page, ctx, "open-minicart", "Abrir o minicart/drawer do carrinho", selFor(ctx, "minicartTrigger"));
5572
5816
  if (recovery) {
5573
5817
  miniHit = recovery;
5574
5818
  miniRecovered = true;
5575
- recoveryBudget--;
5819
+ budget.remaining--;
5576
5820
  }
5577
5821
  }
5578
5822
  let cartOpenMethod = "failed";
@@ -5585,281 +5829,235 @@ async function flowPurchaseJourney(ctx) {
5585
5829
  const alreadyOpen = await isCartRevealed(page, expectedProductTitle);
5586
5830
  if (alreadyOpen) {
5587
5831
  cartOpenMethod = "already-open";
5588
- dlog2(ctx, `step 6 open-minicart: no trigger, but drawer already visible (matched ${alreadyOpen})`);
5832
+ dlog2(ctx, `step 7 open-minicart: no trigger, drawer already visible (${alreadyOpen})`);
5589
5833
  }
5590
5834
  }
5591
- let step6Validation;
5835
+ let step7Validation;
5592
5836
  if (expectedProductTitle && cartOpenMethod !== "failed") {
5593
5837
  const v = await validateCartContainsTitle(page, expectedProductTitle, ctx);
5594
5838
  let reasonText;
5595
5839
  if (!v.found) {
5596
5840
  if (v.observedTitles.length === 0) {
5597
5841
  const emptyBanner = await detectEmptyCartBanner(page);
5598
- reasonText = emptyBanner ? `cart genuinely empty — upstream add-to-cart didn't persist (session/cookie may not have carried over). Banner observed: "${emptyBanner}"` : "no cart line items visible after open (selectors may not match markup)";
5842
+ reasonText = emptyBanner ? `cart genuinely empty — add-to-cart didn't persist. Banner: "${emptyBanner}"` : "no cart line items visible (selectors may not match markup)";
5599
5843
  } else {
5600
5844
  reasonText = `expected title not found among ${v.observedTitles.length} observed`;
5601
5845
  }
5602
5846
  }
5603
- step6Validation = {
5847
+ step7Validation = {
5604
5848
  expectedTitle: expectedProductTitle,
5605
5849
  found: v.found,
5606
5850
  method: v.method,
5607
5851
  observedTitles: v.observedTitles.slice(0, 8),
5608
5852
  reason: reasonText
5609
5853
  };
5610
- dlog2(ctx, `step 6 open-minicart: cart validation → found=${v.found} (${v.method})${reasonText ? ` — ${reasonText.slice(0, 80)}` : ""}`);
5611
- } else if (!expectedProductTitle) {
5612
- dlog2(ctx, "step 6 open-minicart: skipping validation — no PDP title captured");
5613
- } else {
5614
- dlog2(ctx, "step 6 open-minicart: skipping validation — cart UI not revealed");
5854
+ dlog2(ctx, `step 7 open-minicart: validation → found=${v.found} (${v.method})${reasonText ? ` — ${reasonText.slice(0, 80)}` : ""}`);
5615
5855
  }
5616
- const sp6 = screenshotPath(ctx, "pj-6-minicart");
5617
- await page.screenshot({ path: sp6, fullPage: false }).catch(() => {
5856
+ const sp7 = screenshotPath(ctx, "pj-7-minicart");
5857
+ await page.screenshot({ path: sp7, fullPage: false }).catch(() => {
5618
5858
  return;
5619
5859
  });
5620
- const step6Status = cartOpenMethod === "failed" ? "failed" : step6Validation && !step6Validation.found ? "failed" : "ok";
5860
+ const step7Status = cartOpenMethod === "failed" ? "failed" : step7Validation && !step7Validation.found ? "failed" : "ok";
5621
5861
  steps.push({
5622
- step: 6,
5862
+ step: 7,
5623
5863
  name: "open-minicart",
5624
5864
  side: ctx.side,
5625
5865
  viewport: ctx.viewport,
5626
- status: step6Status,
5627
- durationMs: Date.now() - t6,
5866
+ status: step7Status,
5867
+ durationMs: Date.now() - t7,
5628
5868
  url: page.url(),
5629
- screenshotPath: sp6,
5630
- screenshotBeforePath: spBefore6,
5631
- beforeUrl: beforeUrl6,
5869
+ screenshotPath: sp7,
5870
+ screenshotBeforePath: spBefore7,
5871
+ beforeUrl: beforeUrl7,
5632
5872
  cartOpenMethod,
5633
- cartValidation: step6Validation,
5634
- actionDescription: miniHit ? `Abriu minicart via ${cartOpenMethod}${miniText ? ` em '${miniText.slice(0, 30).trim()}'` : ""} (\`${miniHit.selector}\`)${miniRecovered ? " — selector via recovery LLM" : ""}${step6Validation ? step6Validation.found ? " ✓ produto encontrado no cart" : ` ✗ produto ESPERADO não encontrado (${step6Validation.observedTitles?.length ?? 0} itens observados)` : ""}` : cartOpenMethod === "already-open" ? "Minicart já estava aberto após add-to-cart" : "Minicart não pôde ser aberto",
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",
5635
5875
  selectorKey: miniHit ? "minicartTrigger" : undefined,
5636
5876
  usedSelector: miniHit?.selector,
5637
5877
  recoveredByLlm: miniRecovered || undefined
5638
5878
  });
5639
- reportEnd(6, "open-minicart", step6Status, Date.now() - t6);
5640
- reportStart(7, "shipping-calc-cart");
5879
+ reportEnd(7, "open-minicart", step7Status, Date.now() - t7);
5880
+ reportStart(8, "shipping-calc-cart");
5641
5881
  let cepInputCart = await firstVisible(page, selFor(ctx, "cepInputCart"));
5642
- let cepCartRecovered = false;
5643
- if (!cepInputCart && recoveryBudget > 0) {
5644
- const recovery = await attemptRecovery(page, ctx, "shipping-calc-cart", "Achar o input de CEP / código postal dentro do carrinho ou minicart aberto agora (input visível com label/placeholder de frete, entrega ou CEP)", selFor(ctx, "cepInputCart"));
5645
- if (recovery) {
5646
- cepInputCart = recovery.selector;
5647
- cepCartRecovered = true;
5648
- recoveryBudget--;
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
+ }
5649
5900
  }
5650
5901
  }
5651
5902
  if (cepInputCart) {
5652
- const t7 = Date.now();
5653
- const beforeUrl7 = page.url();
5654
- const spBefore7 = screenshotPath(ctx, "pj-7-shipping-cart-before");
5655
- 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(() => {
5656
5907
  return;
5657
5908
  });
5658
5909
  const ok = await fillCep(page, cepInputCart, ctx.rc.cep);
5659
- const sp7 = screenshotPath(ctx, "pj-7-shipping-cart");
5660
- 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(() => {
5661
5912
  return;
5662
5913
  });
5663
- const step7Status = ok ? "ok" : "failed";
5914
+ const step8Status = ok ? "ok" : "failed";
5664
5915
  steps.push({
5665
- step: 7,
5916
+ step: 8,
5666
5917
  name: "shipping-calc-cart",
5667
5918
  side: ctx.side,
5668
5919
  viewport: ctx.viewport,
5669
- status: step7Status,
5670
- durationMs: Date.now() - t7,
5920
+ status: step8Status,
5921
+ durationMs: Date.now() - t8,
5671
5922
  url: page.url(),
5672
- screenshotPath: sp7,
5673
- screenshotBeforePath: spBefore7,
5674
- beforeUrl: beforeUrl7,
5675
- 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" : ""}`,
5676
5927
  detail: { cepUsed: ctx.rc.cep },
5677
5928
  selectorKey: "cepInputCart",
5678
5929
  usedSelector: cepInputCart,
5679
- recoveredByLlm: cepCartRecovered || undefined
5930
+ recoveredByLlm: cepCartRecoveredByLlm || undefined
5680
5931
  });
5681
- reportEnd(7, "shipping-calc-cart", step7Status, Date.now() - t7);
5932
+ reportEnd(8, "shipping-calc-cart", step8Status, Date.now() - t8);
5682
5933
  } else {
5683
- steps.push(makeSkipStep(7, "shipping-calc-cart", ctx, "no CEP input in cart (recovery exhausted)"));
5684
- reportEnd(7, "shipping-calc-cart", "skipped", 0, "no CEP input in cart");
5685
- }
5686
- reportStart(8, "go-checkout");
5687
- const beforeStep8Url = page.url();
5688
- const alreadyInCheckoutPage = /\/(checkout|cart|carrinho)(\/|#|$|\?)/i.test(beforeStep8Url);
5689
- dlog2(ctx, `step 8 go-checkout: beforeUrl=${beforeStep8Url} alreadyInCheckoutPage=${alreadyInCheckoutPage}`);
5690
- if (!alreadyInCheckoutPage) {
5691
- const drawerStillOpen = await firstVisible(page, [
5692
- "[role='dialog']:visible",
5693
- "[aria-modal='true']:visible",
5694
- "[data-minicart][aria-hidden='false']",
5695
- ".minicart--open",
5696
- ".minicart-drawer:not([hidden])",
5697
- "[class*='minicart'][class*='open']",
5698
- "[class*='cart-drawer'][class*='open']"
5699
- ]);
5700
- if (!drawerStillOpen) {
5701
- dlog2(ctx, "step 8 go-checkout: drawer appears closed, re-clicking minicart trigger");
5702
- const reTrigger = await firstVisibleLocator(page, selFor(ctx, "minicartTrigger"));
5703
- if (reTrigger) {
5704
- await reTrigger.locator.click({ timeout: 3000 }).catch(() => {
5705
- return;
5706
- });
5707
- await page.waitForTimeout(1500);
5708
- dlog2(ctx, `step 8 go-checkout: re-opened drawer via ${reTrigger.selector}`);
5709
- } else {
5710
- dlog2(ctx, "step 8 go-checkout: minicartTrigger not found — drawer state unknown");
5711
- }
5712
- } else {
5713
- dlog2(ctx, `step 8 go-checkout: drawer still open (matched ${drawerStillOpen})`);
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
+ };
5714
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 };
5715
5973
  }
5716
- const nextStepSelectors = alreadyInCheckoutPage ? [
5717
- "button:has-text('Continuar para pagamento')",
5718
- "a:has-text('Continuar para pagamento')",
5719
- "button:has-text('Ir para o pagamento')",
5720
- "a:has-text('Ir para o pagamento')",
5721
- "button:has-text('Continuar')",
5722
- "a:has-text('Continuar')",
5723
- "button:has-text('Avançar')",
5724
- "a:has-text('Avançar')",
5725
- "button:has-text('Próxima etapa')",
5726
- "a:has-text('Próxima etapa')",
5727
- "button:has-text('Confirmar')",
5728
- "button:has-text('Finalizar')",
5729
- "a:has-text('Finalizar')",
5730
- "#cart-to-orderform",
5731
- "#btn-go-to-payment",
5732
- ".btn-go-to-payment",
5733
- "a.btn-place-order",
5734
- "a.orange-btn",
5735
- ".cart-links-bottom a",
5736
- "button[type='submit'][class*='checkout' i]",
5737
- "button[type='submit'][class*='continue' i]",
5738
- "[data-checkout-next]",
5739
- "[data-fs-cart-checkout-button]"
5740
- ] : [];
5741
- const baseSelectors = selFor(ctx, "checkoutButton");
5742
- const checkoutSelectors = [...nextStepSelectors, ...baseSelectors];
5743
- dlog2(ctx, `step 8 go-checkout: firstVisibleLocator on ${checkoutSelectors.length} selectors${alreadyInCheckoutPage ? ` (${nextStepSelectors.length} next-step prefixed)` : ""}`);
5744
- let checkoutHit = await firstVisibleLocator(page, checkoutSelectors);
5745
- dlog2(ctx, `step 8 go-checkout: default match → ${checkoutHit ? `selector=${checkoutHit.selector}` : "null"}`);
5974
+ let checkoutHit = await firstVisibleLocator(page, selFor(ctx, "checkoutButton"));
5746
5975
  let checkoutRecovered = false;
5747
- if (!checkoutHit && recoveryBudget > 0) {
5748
- const recoveryIntent = alreadyInCheckoutPage ? "Já estou no checkout (URL contém /checkout ou /cart). Achar o botão visível que avança pra próxima etapa do checkout (geralmente 'Continuar para pagamento', 'Continuar', 'Avançar', 'Próxima etapa', 'Confirmar', ou similar). NÃO é o ícone do header — é uma CTA primary na página." : "Clicar no botão 'Finalizar compra' / 'Ir para o checkout' / 'Finalizar' dentro do drawer/popup do carrinho aberto.";
5749
- dlog2(ctx, `step 8 go-checkout: defaults missed, calling attemptRecovery (budget=${recoveryBudget}, mode=${alreadyInCheckoutPage ? "advance-checkout" : "drawer-finalize"})`);
5750
- const recovery = await attemptRecovery(page, ctx, "go-checkout", recoveryIntent, checkoutSelectors);
5751
- dlog2(ctx, `step 8 go-checkout: LLM recovery → ${recovery ? `selector=${recovery.selector}` : "null"}`);
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"));
5752
5978
  if (recovery) {
5753
5979
  checkoutHit = recovery;
5754
5980
  checkoutRecovered = true;
5755
- recoveryBudget--;
5981
+ budget.remaining--;
5756
5982
  }
5757
5983
  }
5758
5984
  if (!checkoutHit) {
5759
- dlog2(ctx, "step 8 go-checkout: no button found, skipping");
5760
- steps.push(makeSkipStep(8, "go-checkout", ctx, "no checkout button found (recovery exhausted)"));
5761
- 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");
5762
5987
  return { pages, steps };
5763
5988
  }
5764
- const tryCheckoutClick = async (hit, attempt2) => {
5765
- const spBefore = screenshotPath(ctx, `pj-8-checkout-before-${attempt2}`);
5766
- await page.screenshot({ path: spBefore, fullPage: false }).catch(() => {
5767
- return;
5768
- });
5769
- const clickedText2 = await hit.locator.innerText().catch(() => "");
5770
- const urlBefore = page.url();
5771
- const urlChangePredicate = alreadyInCheckoutPage ? (u) => u.toString() !== urlBefore : /checkout/i;
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++;
5772
6000
  await Promise.all([
5773
- page.waitForURL(urlChangePredicate, { timeout: 1e4 }).catch(() => {
6001
+ page.waitForURL(/\/(checkout|carrinho)/i, { timeout: 1e4 }).catch(() => {
5774
6002
  return;
5775
6003
  }),
5776
- hit.locator.click({ timeout: 5000 }).catch(() => {
6004
+ checkoutHit.locator.click({ timeout: 5000 }).catch(() => {
5777
6005
  return;
5778
6006
  })
5779
6007
  ]);
5780
6008
  await page.waitForTimeout(1500);
5781
- const spAfter = screenshotPath(ctx, `pj-8-checkout-reached-${attempt2}`);
5782
- await page.screenshot({ path: spAfter, fullPage: false }).catch(() => {
5783
- return;
5784
- });
5785
- return { url: page.url(), spBefore, spAfter, clickedText: clickedText2 };
5786
- };
5787
- const t8 = Date.now();
5788
- const beforeUrl8 = page.url();
5789
- const CHECKOUT_URL_MARKERS = /\/(checkout|checkouts|finalize|finalizar|pagamento|payment|pedido|order|orderform|onepage|secure|seguro)(\/|#|$|\?|-)/i;
5790
- const isReachedCheckout = (finalUrl) => {
5791
- if (finalUrl === beforeUrl8)
5792
- return false;
5793
- return CHECKOUT_URL_MARKERS.test(finalUrl);
5794
- };
5795
- let attempt = 1;
5796
- dlog2(ctx, `step 8 go-checkout: tryCheckoutClick attempt 1 — selector=${checkoutHit.selector}, beforeUrl=${beforeUrl8}`);
5797
- let result = await tryCheckoutClick(checkoutHit, attempt);
5798
- let reachedCheckout = isReachedCheckout(result.url);
5799
- let usedSelector = checkoutHit.selector;
5800
- let clickedText = result.clickedText;
5801
- dlog2(ctx, `step 8 go-checkout: attempt 1 result — finalUrl=${result.url} reached=${reachedCheckout} clicked='${clickedText.slice(0, 40).trim()}'`);
5802
- if (!reachedCheckout && recoveryBudget > 0) {
5803
- dlog2(ctx, `step 8 go-checkout: attempt 1 missed target — calling attemptRecovery for retry (budget=${recoveryBudget}, mode=${alreadyInCheckoutPage ? "advance-checkout" : "drawer-finalize"})`);
5804
- const retryIntent = alreadyInCheckoutPage ? `Já estou no checkout, em ${beforeUrl8}. Cliquei em '${clickedText.slice(0, 40).trim()}' (selector \`${usedSelector}\`), mas a URL não mudou ou foi pra ${result.url}. Achar o botão que avança pra próxima etapa do checkout (Continuar para pagamento, Avançar, Próxima etapa, Confirmar, etc).` : `Cliquei em '${clickedText.slice(0, 40).trim()}' (selector \`${usedSelector}\`), mas a URL ficou em ${result.url} e não foi pra /checkout. Achar o botão que de fato navega pra /checkout neste cart/minicart aberto.`;
5805
- const retrySuggestion = await attemptRecovery(page, ctx, "go-checkout-retry", retryIntent, [usedSelector, ...checkoutSelectors]);
5806
- dlog2(ctx, `step 8 go-checkout: retry LLM suggestion → ${retrySuggestion ? `selector=${retrySuggestion.selector}` : "null"}`);
5807
- if (retrySuggestion) {
5808
- recoveryBudget--;
5809
- checkoutRecovered = true;
5810
- attempt++;
5811
- dlog2(ctx, `step 8 go-checkout: tryCheckoutClick attempt 2 — selector=${retrySuggestion.selector}`);
5812
- const retryResult = await tryCheckoutClick(retrySuggestion, attempt);
5813
- result = retryResult;
5814
- usedSelector = retrySuggestion.selector;
5815
- clickedText = retryResult.clickedText;
5816
- reachedCheckout = isReachedCheckout(retryResult.url);
5817
- dlog2(ctx, `step 8 go-checkout: attempt 2 result — finalUrl=${retryResult.url} reached=${reachedCheckout} clicked='${retryResult.clickedText.slice(0, 40).trim()}'`);
5818
- }
5819
- } else if (!reachedCheckout) {
5820
- dlog2(ctx, `step 8 go-checkout: not retrying — recoveryBudget=${recoveryBudget}`);
5821
- }
5822
- let step8Validation;
5823
- if (expectedProductTitle && reachedCheckout) {
5824
- await page.waitForTimeout(1500);
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);
5825
6029
  const v = await validateCartContainsTitle(page, expectedProductTitle, ctx);
5826
- let reasonText;
5827
- if (!v.found) {
5828
- if (v.observedTitles.length === 0) {
5829
- const emptyBanner = await detectEmptyCartBanner(page);
5830
- reasonText = emptyBanner ? `checkout shows empty cart banner — upstream session lost. Banner: "${emptyBanner}"` : "checkout page has no visible line items (selectors may not match markup)";
5831
- } else {
5832
- reasonText = `expected title not found among ${v.observedTitles.length} observed on checkout`;
5833
- }
5834
- }
5835
- step8Validation = {
6030
+ step9Validation = {
5836
6031
  expectedTitle: expectedProductTitle,
5837
6032
  found: v.found,
5838
6033
  method: v.method,
5839
6034
  observedTitles: v.observedTitles.slice(0, 8),
5840
- reason: reasonText
6035
+ reason: v.found ? undefined : `expected title not found among ${v.observedTitles.length} observed on checkout`
5841
6036
  };
5842
- dlog2(ctx, `step 8 go-checkout: checkout validation → found=${v.found} (${v.method})${reasonText ? ` — ${reasonText.slice(0, 80)}` : ""}`);
5843
6037
  }
5844
- const step8Status = reachedCheckout ? step8Validation && !step8Validation.found ? "failed" : "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";
5845
6043
  steps.push({
5846
- step: 8,
6044
+ step: 9,
5847
6045
  name: "go-checkout",
5848
6046
  side: ctx.side,
5849
6047
  viewport: ctx.viewport,
5850
- status: step8Status,
5851
- durationMs: Date.now() - t8,
5852
- url: result.url,
5853
- screenshotPath: result.spAfter,
5854
- screenshotBeforePath: result.spBefore,
5855
- beforeUrl: beforeUrl8,
5856
- cartValidation: step8Validation,
5857
- actionDescription: `Clicou em${clickedText ? ` '${clickedText.slice(0, 30).trim()}'` : ""} (\`${usedSelector}\`); URL final: ${result.url}${reachedCheckout ? " ✓ atingiu /checkout" : " ✗ não foi pra checkout"}${attempt > 1 ? ` (após ${attempt} tentativas com recovery LLM)` : ""}${step8Validation ? step8Validation.found ? " ✓ produto persiste no checkout" : ` ✗ produto ESPERADO ausente no checkout (${step8Validation.observedTitles?.length ?? 0} itens observados)` : ""}`,
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)` : ""}`,
5858
6056
  selectorKey: "checkoutButton",
5859
6057
  usedSelector,
5860
6058
  recoveredByLlm: checkoutRecovered || undefined
5861
6059
  });
5862
- reportEnd(8, "go-checkout", step8Status, Date.now() - t8);
6060
+ reportEnd(9, "go-checkout", step9Status, Date.now() - t9);
5863
6061
  return { pages, steps };
5864
6062
  } finally {
5865
6063
  await page.close();
@@ -5879,15 +6077,10 @@ function makeSkipStep(step, name, ctx, note) {
5879
6077
  }
5880
6078
  async function findCategoryUrl(page, ctx) {
5881
6079
  const selectors = selFor(ctx, "categoryLink");
5882
- dlog2(ctx, ` findCategoryUrl: collectCandidateLinks(${selectors.length} selectors) start`);
5883
- const t0 = Date.now();
5884
6080
  const candidates = await collectCandidateLinks(page, selectors, 12);
5885
- dlog2(ctx, ` findCategoryUrl: collectCandidateLinks done in ${Date.now() - t0}ms → ${candidates.length} candidates`);
5886
6081
  if (candidates.length === 0)
5887
6082
  return null;
5888
- dlog2(ctx, " findCategoryUrl: pickCategoryLink LLM call start");
5889
6083
  const picked = await pickCategoryLink(candidates.map((c) => ({ text: c.text, href: c.href })));
5890
- dlog2(ctx, ` findCategoryUrl: pickCategoryLink LLM done → ${picked?.href ?? "null"}`);
5891
6084
  if (!picked)
5892
6085
  return null;
5893
6086
  const original = candidates.find((c) => c.href === picked.href);
@@ -5936,34 +6129,20 @@ async function firstVisibleLocator(page, selectors) {
5936
6129
  }
5937
6130
  return null;
5938
6131
  }
5939
- var COLLECT_CANDIDATES_BUDGET_MS = 15000;
5940
- function withCap(p, capMs, fallback) {
5941
- return Promise.race([
5942
- p.catch(() => fallback),
5943
- new Promise((resolve) => setTimeout(() => resolve(fallback), capMs))
5944
- ]);
5945
- }
5946
6132
  async function collectCandidateLinks(page, selectors, limit = 12) {
5947
6133
  const out = [];
5948
6134
  const seenHrefs = new Set;
5949
- const deadline = Date.now() + COLLECT_CANDIDATES_BUDGET_MS;
5950
- const expired = () => Date.now() >= deadline;
5951
6135
  for (const sel of selectors) {
5952
6136
  if (out.length >= limit)
5953
6137
  break;
5954
- if (expired())
5955
- break;
5956
6138
  try {
5957
6139
  const elements = page.locator(sel);
5958
- const count = await withCap(elements.count(), 1000, 0);
6140
+ const count = await elements.count();
5959
6141
  for (let i = 0;i < count && out.length < limit; i++) {
5960
- if (expired())
5961
- break;
5962
6142
  const el = elements.nth(i);
5963
- const visible = await withCap(el.isVisible({ timeout: 250 }).catch(() => false), 400, false);
5964
- if (!visible)
6143
+ if (!await el.isVisible({ timeout: 250 }).catch(() => false))
5965
6144
  continue;
5966
- const href = await withCap(el.getAttribute("href").catch(() => null), 400, null);
6145
+ const href = await el.getAttribute("href").catch(() => null);
5967
6146
  if (!href)
5968
6147
  continue;
5969
6148
  let abs = href;
@@ -5975,7 +6154,7 @@ async function collectCandidateLinks(page, selectors, limit = 12) {
5975
6154
  if (seenHrefs.has(abs))
5976
6155
  continue;
5977
6156
  seenHrefs.add(abs);
5978
- const text = (await withCap(el.innerText().catch(() => ""), 400, "")).slice(0, 60).trim();
6157
+ const text = (await el.innerText().catch(() => "")).slice(0, 60).trim();
5979
6158
  out.push({ text, href: abs, selector: sel });
5980
6159
  }
5981
6160
  } catch {}
@@ -5994,6 +6173,360 @@ async function fillCep(page, selector, cep) {
5994
6173
  return false;
5995
6174
  }
5996
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
+ }
6438
+ async function attemptRecovery(page, _ctx, stepName, intendedAction, alreadyTried) {
6439
+ let html = "";
6440
+ try {
6441
+ html = await page.content();
6442
+ } catch {
6443
+ return null;
6444
+ }
6445
+ const suggestion = await suggestRecovery({ stepName, intendedAction, html, alreadyTried });
6446
+ if (!suggestion)
6447
+ return null;
6448
+ try {
6449
+ const el = page.locator(suggestion.selector).first();
6450
+ if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
6451
+ return { locator: el, selector: suggestion.selector };
6452
+ }
6453
+ } catch {}
6454
+ return null;
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
+ }
5997
6530
  var GENERIC_TITLE_PATTERNS = [
5998
6531
  /^p[áa]gina de produto/i,
5999
6532
  /^product page/i,
@@ -6246,7 +6779,7 @@ async function openMinicart(page, trigger, ctx, expectedProductTitle) {
6246
6779
  }
6247
6780
  })();
6248
6781
  if (targetUrl) {
6249
- dlog2(ctx, ` openMinicart: all interactive strategies failed but trigger has cart href, navigating directly to ${targetUrl}`);
6782
+ dlog2(ctx, ` openMinicart: all interactive strategies failed; navigating directly to ${targetUrl}`);
6250
6783
  await page.goto(targetUrl, { waitUntil: "load", timeout: 15000 }).catch(() => {
6251
6784
  return;
6252
6785
  });
@@ -6345,7 +6878,7 @@ async function validateCartContainsTitle(page, expectedTitle, ctx) {
6345
6878
  await page.waitForTimeout(2000);
6346
6879
  observed = await sweepTitles();
6347
6880
  }
6348
- dlog2(ctx, ` validateCartContainsTitle: observed ${observed.length} titles ${observed.length ? `[${observed.slice(0, 3).map((t) => t.slice(0, 30)).join(" | ")}${observed.length > 3 ? " | …" : ""}]` : ""}`);
6881
+ dlog2(ctx, ` validateCartContainsTitle: observed ${observed.length} titles`);
6349
6882
  if (observed.length === 0) {
6350
6883
  return { found: false, observedTitles: [], method: "none" };
6351
6884
  }
@@ -6374,24 +6907,6 @@ async function detectEmptyCartBanner(page) {
6374
6907
  }
6375
6908
  return null;
6376
6909
  }
6377
- async function attemptRecovery(page, _ctx, stepName, intendedAction, alreadyTried) {
6378
- let html = "";
6379
- try {
6380
- html = await page.content();
6381
- } catch {
6382
- return null;
6383
- }
6384
- const suggestion = await suggestRecovery({ stepName, intendedAction, html, alreadyTried });
6385
- if (!suggestion)
6386
- return null;
6387
- try {
6388
- const el = page.locator(suggestion.selector).first();
6389
- if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
6390
- return { locator: el, selector: suggestion.selector };
6391
- }
6392
- } catch {}
6393
- return null;
6394
- }
6395
6910
 
6396
6911
  // src/ignore/parser.ts
6397
6912
  import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
@@ -6501,6 +7016,51 @@ function detectFromHtml(html) {
6501
7016
  return "custom";
6502
7017
  }
6503
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
+
6504
7064
  // src/llm/discover-selectors.ts
6505
7065
  import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "node:fs";
6506
7066
  import { join as join6 } from "node:path";
@@ -6526,26 +7086,26 @@ var DISCOVER_SELECTORS_TOOL = {
6526
7086
  },
6527
7087
  minicart_trigger: {
6528
7088
  type: "string",
6529
- 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.`
6530
7090
  },
6531
7091
  cep_input_pdp: {
6532
7092
  type: "string",
6533
- 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."
6534
7094
  },
6535
7095
  cep_input_cart: {
6536
7096
  type: "string",
6537
- 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."
6538
7098
  },
6539
7099
  checkout_button: {
6540
7100
  type: "string",
6541
- 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`."
6542
7102
  },
6543
7103
  reasoning: {
6544
7104
  type: "string",
6545
7105
  description: "1-2 sentence rationale describing what was inferred from the markup."
6546
7106
  }
6547
7107
  },
6548
- required: ["category_link", "product_card", "buy_button", "minicart_trigger", "checkout_button"]
7108
+ required: ["category_link", "product_card", "buy_button", "minicart_trigger"]
6549
7109
  }
6550
7110
  };
6551
7111
  var SYSTEM_PROMPT = `
@@ -6582,7 +7142,17 @@ REGRAS:
6582
7142
  4. Para "cep_input_pdp" e "cep_input_cart": retorne string vazia se não existir esse recurso na plataforma.
6583
7143
  Não invente.
6584
7144
 
6585
- 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?"
6586
7156
 
6587
7157
  Responda SEMPRE via tool_use report_selectors. Não escreva texto livre fora da tool call.
6588
7158
  `.trim();
@@ -6656,12 +7226,24 @@ ${compacted}
6656
7226
  cepInputCart: emptyToUndef(input.cep_input_cart),
6657
7227
  checkoutButton: emptyToUndef(input.checkout_button)
6658
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
+ }
6659
7233
  if (!existsSync6(cacheDir))
6660
7234
  mkdirSync4(cacheDir, { recursive: true });
6661
7235
  writeFileSync5(cachePath, `${JSON.stringify(selectors, null, 2)}
6662
7236
  `, "utf8");
6663
7237
  return selectors;
6664
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
+ }
6665
7247
  function emptyToUndef(v) {
6666
7248
  if (v == null)
6667
7249
  return;
@@ -6681,11 +7263,12 @@ var STEP_LABELS = {
6681
7263
  "visit-home": "1. visit-home",
6682
7264
  "navigate-plp": "2. navigate-plp",
6683
7265
  "enter-pdp": "3. enter-pdp",
6684
- "shipping-calc-pdp": "4. shipping-calc-pdp",
6685
- "add-to-cart": "5. add-to-cart",
6686
- "open-minicart": "6. open-minicart",
6687
- "shipping-calc-cart": "7. shipping-calc-cart",
6688
- "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"
6689
7272
  };
6690
7273
  var CRITICAL_STEPS = new Set([
6691
7274
  "visit-home",
@@ -6814,6 +7397,39 @@ async function journeyCommand(opts) {
6814
7397
  }
6815
7398
  if (!opts.json)
6816
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
+ }
6817
7433
  const rows = buildRows(flowCaptures, viewports);
6818
7434
  const failures = collectFailures(rows);
6819
7435
  writeRunReportJson(paths.runDir, buildJourneyRun(opts, runId, flowCaptures, failures));
@@ -7171,6 +7787,13 @@ function renderSummaryBlock(rows, failures) {
7171
7787
  function escapeHtml(s) {
7172
7788
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
7173
7789
  }
7790
+ function safeHost2(url) {
7791
+ try {
7792
+ return new URL(url).hostname;
7793
+ } catch {
7794
+ return url;
7795
+ }
7796
+ }
7174
7797
 
7175
7798
  // src/commands/learned.ts
7176
7799
  import chalk7 from "chalk";
@@ -8489,6 +9112,7 @@ var STEP_LABELS2 = {
8489
9112
  "visit-home": "Visitar home",
8490
9113
  "navigate-plp": "Navegar para categoria (PLP)",
8491
9114
  "enter-pdp": "Entrar em PDP",
9115
+ "select-variant": "Selecionar variante (tamanho/cor/quantidade)",
8492
9116
  "shipping-calc-pdp": "Cálculo de frete na PDP",
8493
9117
  "add-to-cart": "Adicionar ao carrinho",
8494
9118
  "open-minicart": "Abrir minicart",
@@ -10383,21 +11007,9 @@ async function runCommand(rawOpts) {
10383
11007
  for (const p of cap.pages)
10384
11008
  allPageCaptures.push(p);
10385
11009
  if (opts.learn !== false) {
10386
- for (const step of cap.steps ?? []) {
10387
- if (!step.selectorKey || !step.usedSelector)
10388
- continue;
10389
- const key = step.selectorKey;
10390
- if (step.recoveredByLlm) {
10391
- promoteFromLlm(learned, platform, key, step.usedSelector, prodHost);
10392
- promotedCount++;
10393
- } else if (step.status === "ok") {
10394
- recordSuccess(learned, platform, key, step.usedSelector, prodHost);
10395
- } else if (step.status === "failed") {
10396
- const before = recordFailure(learned, platform, key, step.usedSelector, prodHost);
10397
- if (before?.deprecated)
10398
- deprecatedCount++;
10399
- }
10400
- }
11010
+ const result = promoteStepsFromFlow(learned, platform, prodHost, cap);
11011
+ promotedCount += result.promoted;
11012
+ deprecatedCount += result.deprecated;
10401
11013
  }
10402
11014
  }
10403
11015
  await stopTracing(ctx, tracePath).catch(() => {