@decocms/parity 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/cli.js +1572 -426
  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
@@ -107,7 +91,15 @@ var StepCapture = z.object({
107
91
  observedTitles: z.array(z.string()).optional(),
108
92
  reason: z.string().optional()
109
93
  }).optional(),
110
- cartOpenMethod: z.enum(["click", "click-navigate", "hover", "already-open", "failed"]).optional()
94
+ cartOpenMethod: z.enum(["click", "click-navigate", "hover", "already-open", "failed"]).optional(),
95
+ cartRevealMode: z.enum([
96
+ "hover-drawer",
97
+ "click-drawer",
98
+ "click-navigate-checkout",
99
+ "click-navigate-cart",
100
+ "inline-notification",
101
+ "unknown"
102
+ ]).optional()
111
103
  });
112
104
  var FlowCapture = z.object({
113
105
  flow: FlowName,
@@ -302,7 +294,13 @@ var ParityRc = z.object({
302
294
  minicartTrigger: z.string().optional(),
303
295
  cepInputPdp: z.string().optional(),
304
296
  cepInputCart: z.string().optional(),
305
- checkoutButton: z.string().optional()
297
+ checkoutButton: z.string().optional(),
298
+ sizeSwatch: z.string().optional(),
299
+ colorSwatch: z.string().optional(),
300
+ variantRow: z.string().optional(),
301
+ quantityIncrement: z.string().optional(),
302
+ quantityInput: z.string().optional(),
303
+ minicartCount: z.string().optional()
306
304
  }).default({}),
307
305
  skipSteps: z.array(z.string()).default([])
308
306
  });
@@ -874,6 +872,138 @@ function diffSitemap(prodUrls, candUrls) {
874
872
 
875
873
  // src/engine/browser.ts
876
874
  import { chromium, devices } from "playwright";
875
+
876
+ // src/engine/carousel-stabilizer.ts
877
+ var CAROUSEL_STABILIZER_INIT_SCRIPT = `
878
+ (function () {
879
+ if (window.__parityCarouselInstalled) return;
880
+ window.__parityCarouselInstalled = true;
881
+ window.__parityCarouselStop = false;
882
+
883
+ /**
884
+ * Walks the page for known carousel libraries and pins them to slide 0.
885
+ * Idempotent — safe to call multiple times. Returns a counters object
886
+ * so the harness can log how many were stabilized.
887
+ */
888
+ window.__parityStabilizeCarousels = function () {
889
+ var counts = { swiper: 0, splide: 0, slick: 0, keenSlider: 0, generic: 0 };
890
+
891
+ // 1. Swiper — most common in Deco sites. Two ways to find instances:
892
+ // - element.swiper (newer versions)
893
+ // - window.Swiper.instances (older)
894
+ try {
895
+ var swiperEls = document.querySelectorAll('.swiper, .swiper-container');
896
+ swiperEls.forEach(function (el) {
897
+ var inst = el.swiper;
898
+ if (inst && typeof inst.slideTo === 'function') {
899
+ try { inst.autoplay && inst.autoplay.stop && inst.autoplay.stop(); } catch (_) {}
900
+ try { inst.slideTo(0, 0, false); counts.swiper++; } catch (_) {}
901
+ }
902
+ });
903
+ } catch (_) {}
904
+
905
+ // 2. Splide
906
+ try {
907
+ var splideEls = document.querySelectorAll('.splide');
908
+ splideEls.forEach(function (el) {
909
+ var inst = el.splide || (el.__splide);
910
+ if (inst && typeof inst.go === 'function') {
911
+ try { inst.go(0); counts.splide++; } catch (_) {}
912
+ }
913
+ });
914
+ } catch (_) {}
915
+
916
+ // 3. Slick
917
+ try {
918
+ if (window.jQuery) {
919
+ var $slick = window.jQuery('.slick-slider, .slick-initialized');
920
+ if ($slick.length) {
921
+ try { $slick.slick('slickGoTo', 0, true); counts.slick += $slick.length; } catch (_) {}
922
+ }
923
+ }
924
+ } catch (_) {}
925
+
926
+ // 4. KeenSlider
927
+ try {
928
+ var keenEls = document.querySelectorAll('.keen-slider');
929
+ keenEls.forEach(function (el) {
930
+ var inst = el.__keenSlider || el.keenSlider;
931
+ if (inst && typeof inst.moveToIdx === 'function') {
932
+ try { inst.moveToIdx(0, true, { duration: 0 }); counts.keenSlider++; } catch (_) {}
933
+ }
934
+ });
935
+ } catch (_) {}
936
+
937
+ // 5. Generic fallback — any element inside a [data-section] matching
938
+ // carousel|slider|banner|hero, reset scrollLeft to 0 on any
939
+ // scrolled descendant. Catches CSS-only scroll-snap carousels
940
+ // and custom Deco implementations.
941
+ //
942
+ // Cubic flagged that the previous version did
943
+ // querySelectorAll('*') + getComputedStyle per node — O(N) layout
944
+ // flushes on every screenshot. Now we only touch elements with
945
+ // non-zero scrollLeft (a DOM-level read, no layout flush) which
946
+ // are the only ones we'd mutate anyway. Skips the entire
947
+ // getComputedStyle scan.
948
+ try {
949
+ var BANNER_RE = /(carousel|slider|banner|hero)/i;
950
+ var sections = document.querySelectorAll('[data-section]');
951
+ for (var s = 0; s < sections.length; s++) {
952
+ var sec = sections[s];
953
+ var name = sec.getAttribute('data-section') || '';
954
+ if (!BANNER_RE.test(name)) continue;
955
+ try { if (sec.scrollLeft > 0) { sec.scrollLeft = 0; counts.generic++; } } catch (_) {}
956
+ var scrollers = sec.querySelectorAll('*');
957
+ for (var i = 0; i < scrollers.length; i++) {
958
+ var el = scrollers[i];
959
+ if (el && el.scrollLeft > 0) {
960
+ try { el.scrollLeft = 0; counts.generic++; } catch (_) {}
961
+ }
962
+ }
963
+ }
964
+ } catch (_) {}
965
+
966
+ // 6. Set the freeze flag so future auto-advance ticks bail out (libs
967
+ // that check this flag before mutating slide index).
968
+ window.__parityCarouselStop = true;
969
+ return counts;
970
+ };
971
+ })();
972
+ `.trim();
973
+ async function stabilizeCarousels(page) {
974
+ const empty = {
975
+ swiper: 0,
976
+ splide: 0,
977
+ slick: 0,
978
+ keenSlider: 0,
979
+ generic: 0,
980
+ total: 0
981
+ };
982
+ try {
983
+ const counts = await page.evaluate(() => {
984
+ const fn = window.__parityStabilizeCarousels;
985
+ return fn ? fn() : null;
986
+ });
987
+ if (!counts)
988
+ return empty;
989
+ const total = (counts.swiper ?? 0) + (counts.splide ?? 0) + (counts.slick ?? 0) + (counts.keenSlider ?? 0) + (counts.generic ?? 0);
990
+ if (total > 0) {
991
+ await page.waitForTimeout(150);
992
+ }
993
+ return {
994
+ swiper: counts.swiper ?? 0,
995
+ splide: counts.splide ?? 0,
996
+ slick: counts.slick ?? 0,
997
+ keenSlider: counts.keenSlider ?? 0,
998
+ generic: counts.generic ?? 0,
999
+ total
1000
+ };
1001
+ } catch {
1002
+ return empty;
1003
+ }
1004
+ }
1005
+
1006
+ // src/engine/browser.ts
877
1007
  var DISABLE_ANIMATIONS_CSS = `
878
1008
  *, *::before, *::after {
879
1009
  animation-duration: 0s !important;
@@ -883,17 +1013,33 @@ var DISABLE_ANIMATIONS_CSS = `
883
1013
  scroll-behavior: auto !important;
884
1014
  }
885
1015
  `;
1016
+ var USER_AGENT_BY_VIEWPORT = {
1017
+ mobile: "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36",
1018
+ tablet: "Mozilla/5.0 (iPad; CPU OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1",
1019
+ desktop: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
1020
+ };
1021
+ function userAgentFor(viewport) {
1022
+ return USER_AGENT_BY_VIEWPORT[viewport];
1023
+ }
886
1024
  var VIEWPORT_PRESETS = {
887
1025
  mobile: {
888
- ...devices["Pixel 7"]
1026
+ ...devices["Pixel 7"],
1027
+ userAgent: USER_AGENT_BY_VIEWPORT.mobile,
1028
+ isMobile: true,
1029
+ hasTouch: true
889
1030
  },
890
1031
  tablet: {
891
- ...devices["iPad Mini"]
1032
+ ...devices["iPad Mini"],
1033
+ userAgent: USER_AGENT_BY_VIEWPORT.tablet,
1034
+ isMobile: true,
1035
+ hasTouch: true
892
1036
  },
893
1037
  desktop: {
894
1038
  viewport: { width: 1440, height: 900 },
895
1039
  deviceScaleFactor: 1,
896
- userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
1040
+ userAgent: USER_AGENT_BY_VIEWPORT.desktop,
1041
+ isMobile: false,
1042
+ hasTouch: false
897
1043
  }
898
1044
  };
899
1045
  async function launchBrowser(opts = {}) {
@@ -909,7 +1055,8 @@ async function newContext(browser, opts) {
909
1055
  ...baseContext,
910
1056
  recordHar: opts.harPath ? { path: opts.harPath, mode: "minimal" } : undefined,
911
1057
  bypassCSP: true,
912
- ignoreHTTPSErrors: true
1058
+ ignoreHTTPSErrors: true,
1059
+ extraHTTPHeaders: opts.noCache ? { "Cache-Control": "no-cache", Pragma: "no-cache" } : undefined
913
1060
  });
914
1061
  await ctx.addInitScript({
915
1062
  content: `
@@ -920,6 +1067,7 @@ async function newContext(browser, opts) {
920
1067
  } catch (e) {}
921
1068
  `
922
1069
  });
1070
+ await ctx.addInitScript({ content: CAROUSEL_STABILIZER_INIT_SCRIPT });
923
1071
  if (opts.cohortCookieValue) {
924
1072
  await ctx.addCookies([
925
1073
  {
@@ -1209,6 +1357,12 @@ async function capturePage(page, opts) {
1209
1357
  new Promise((resolve) => setTimeout(() => resolve(null), Math.min(5000, remaining())))
1210
1358
  ]) ?? null;
1211
1359
  if (!opts.skipScreenshot) {
1360
+ await Promise.race([
1361
+ stabilizeCarousels(page).catch(() => {
1362
+ return;
1363
+ }),
1364
+ new Promise((resolve) => setTimeout(resolve, Math.min(3000, remaining())))
1365
+ ]);
1212
1366
  dlog(opts.side, opts.viewport, ` capturePage: screenshot start (cap=${Math.min(15000, remaining())}ms)`);
1213
1367
  await Promise.race([
1214
1368
  page.screenshot({ path: opts.screenshotPath, fullPage: true, animations: "disabled" }).catch(() => {
@@ -4469,6 +4623,23 @@ async function callAnthropicTool(params) {
4469
4623
  return null;
4470
4624
  }
4471
4625
  async function callOpenRouterTool(params) {
4626
+ const baseTokens = params.maxTokens ?? 2000;
4627
+ const totalBudget = params.timeoutMs ?? defaultTimeout(params);
4628
+ const start = Date.now();
4629
+ for (let attempt = 1;attempt <= 2; attempt++) {
4630
+ const isRetry = attempt > 1;
4631
+ const remaining = totalBudget - (Date.now() - start);
4632
+ if (remaining <= 2000 && isRetry) {
4633
+ return null;
4634
+ }
4635
+ const tokens = isRetry ? Math.max(baseTokens * 2, 4000) : baseTokens;
4636
+ const result = await openRouterToolOnce(params, tokens, isRetry, Math.max(remaining, 2000));
4637
+ if (result !== undefined)
4638
+ return result;
4639
+ }
4640
+ return null;
4641
+ }
4642
+ async function openRouterToolOnce(params, maxTokens, isRetry, attemptTimeoutMs) {
4472
4643
  const apiKey = process.env.OPENROUTER_API_KEY;
4473
4644
  if (!apiKey)
4474
4645
  return null;
@@ -4481,8 +4652,7 @@ async function callOpenRouterTool(params) {
4481
4652
  }
4482
4653
  });
4483
4654
  }
4484
- const timeoutMs = params.timeoutMs ?? defaultTimeout(params);
4485
- const { signal, clear } = makeTimeoutSignal(timeoutMs);
4655
+ const { signal, clear } = makeTimeoutSignal(attemptTimeoutMs);
4486
4656
  try {
4487
4657
  const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
4488
4658
  method: "POST",
@@ -4495,7 +4665,7 @@ async function callOpenRouterTool(params) {
4495
4665
  },
4496
4666
  body: JSON.stringify({
4497
4667
  model: LLM_MODEL_OPENROUTER,
4498
- max_tokens: params.maxTokens ?? 2000,
4668
+ max_tokens: maxTokens,
4499
4669
  messages: [
4500
4670
  { role: "system", content: params.systemPrompt },
4501
4671
  { role: "user", content: userContent }
@@ -4516,6 +4686,8 @@ async function callOpenRouterTool(params) {
4516
4686
  if (!response.ok) {
4517
4687
  const txt = await response.text().catch(() => "");
4518
4688
  console.error(`[llm-openrouter] HTTP ${response.status}: ${txt.slice(0, 200)}`);
4689
+ if (response.status >= 500 || response.status === 429)
4690
+ return;
4519
4691
  return null;
4520
4692
  }
4521
4693
  const json = await response.json();
@@ -4531,8 +4703,12 @@ async function callOpenRouterTool(params) {
4531
4703
  return JSON.parse(repaired);
4532
4704
  } catch {}
4533
4705
  }
4534
- console.error(`[llm-openrouter] failed to parse tool arguments (raw len=${raw.length}, head=${JSON.stringify(raw.slice(0, 80))})`);
4535
- return null;
4706
+ if (isRetry) {
4707
+ console.error(`[llm-openrouter] failed to parse tool arguments after retry (raw len=${raw.length}, head=${JSON.stringify(raw.slice(0, 80))})`);
4708
+ return null;
4709
+ }
4710
+ console.error(`[llm-openrouter] parse error, retrying with more tokens (raw len=${raw.length}, head=${JSON.stringify(raw.slice(0, 80))})`);
4711
+ return;
4536
4712
  }
4537
4713
  }
4538
4714
  const content = json.choices?.[0]?.message?.content;
@@ -4541,12 +4717,17 @@ async function callOpenRouterTool(params) {
4541
4717
  return JSON.parse(content);
4542
4718
  } catch {}
4543
4719
  }
4720
+ return null;
4544
4721
  } catch (err) {
4545
- console.error(`[llm-openrouter] failed: ${err.message}`);
4722
+ const msg = err.message;
4723
+ console.error(`[llm-openrouter] failed: ${msg}`);
4724
+ if (!isRetry && (msg.includes("ECONN") || msg.includes("aborted") || msg.includes("fetch"))) {
4725
+ return;
4726
+ }
4727
+ return null;
4546
4728
  } finally {
4547
4729
  clear();
4548
4730
  }
4549
- return null;
4550
4731
  }
4551
4732
  function tryRepairJson(raw) {
4552
4733
  let s = raw.trim();
@@ -4962,7 +5143,13 @@ var SelectorKey = z2.enum([
4962
5143
  "minicartTrigger",
4963
5144
  "cepInputPdp",
4964
5145
  "cepInputCart",
4965
- "checkoutButton"
5146
+ "checkoutButton",
5147
+ "sizeSwatch",
5148
+ "colorSwatch",
5149
+ "variantRow",
5150
+ "quantityIncrement",
5151
+ "quantityInput",
5152
+ "minicartCount"
4966
5153
  ]);
4967
5154
  var SelectorEntry = z2.object({
4968
5155
  selector: z2.string(),
@@ -5156,8 +5343,80 @@ var DEFAULT_SELECTORS = {
5156
5343
  "button:has-text('Finalizar compra')",
5157
5344
  "a:has-text('Ir para o checkout')",
5158
5345
  "button:has-text('Ir para o checkout')",
5346
+ "[role='dialog'] a:has-text('Finalizar')",
5347
+ "[role='dialog'] button:has-text('Finalizar')",
5348
+ "[data-minicart] a:has-text('Finalizar')",
5349
+ "[data-minicart] button:has-text('Finalizar')",
5350
+ "[data-cart-drawer] a:has-text('Finalizar')",
5351
+ "[data-cart-drawer] button:has-text('Finalizar')",
5159
5352
  "a:has-text('Checkout')",
5160
- "[data-checkout-button]"
5353
+ "[data-checkout-button]",
5354
+ "a:text-is('Finalizar')",
5355
+ "button:text-is('Finalizar')"
5356
+ ],
5357
+ sizeSwatch: [
5358
+ "[data-size]:not([disabled]):not([aria-disabled='true']):not(.unavailable):not(.sold-out)",
5359
+ "[data-variant-size]:not([disabled]):not([aria-disabled='true']):not(.unavailable)",
5360
+ "button[aria-label*='tamanho' i]:not([aria-disabled='true']):not([disabled])",
5361
+ "button[aria-label*='size' i]:not([aria-disabled='true']):not([disabled])",
5362
+ "[data-testid='size-selector'] button:not([disabled]):not(.unavailable)",
5363
+ ".size-selector button:not([disabled]):not(.unavailable):not(.sold-out)",
5364
+ "[class*='size'] button:not([disabled]):not(.unavailable)",
5365
+ "label:has(input[name*='size' i]:not([disabled])) "
5366
+ ],
5367
+ colorSwatch: [
5368
+ "[data-color]:not([disabled]):not([aria-disabled='true']):not(.unavailable):not(.sold-out)",
5369
+ "[data-variant-color]:not([disabled]):not([aria-disabled='true']):not(.unavailable)",
5370
+ "button[aria-label*='cor' i]:not([aria-disabled='true']):not([disabled])",
5371
+ "button[aria-label*='color' i]:not([aria-disabled='true']):not([disabled])",
5372
+ "[data-testid='color-selector'] button:not([disabled]):not(.unavailable)",
5373
+ ".color-swatch:not(.unavailable):not(.sold-out)",
5374
+ "[class*='color'] button:not([disabled]):not(.unavailable)"
5375
+ ],
5376
+ variantRow: [
5377
+ "[data-sku-row]",
5378
+ "[data-variant-row]",
5379
+ "tr[data-variant]",
5380
+ "tr:has([aria-label*='quantidade' i])",
5381
+ "tbody tr:has(input[type='number'])"
5382
+ ],
5383
+ quantityIncrement: [
5384
+ "[data-qty-plus]",
5385
+ "[data-quantity-plus]",
5386
+ "button[aria-label*='aumentar' i]",
5387
+ "button[aria-label*='increase' i]",
5388
+ "button[aria-label*='increment' i]",
5389
+ "button:has-text('+')",
5390
+ "[class*='qty'] button:has-text('+')"
5391
+ ],
5392
+ quantityInput: [
5393
+ "input[type='number'][min='0'][value='0']",
5394
+ "input[id*='quantity-input' i]",
5395
+ "input[name*='qty' i]",
5396
+ "input[name*='quantity' i]",
5397
+ "input[name*='quantidade' i]",
5398
+ "input[aria-label*='quantidade' i]",
5399
+ "[data-qty-input]",
5400
+ "input[type='number'][min='0']",
5401
+ "input[type='number'][min='1']"
5402
+ ],
5403
+ minicartCount: [
5404
+ "[data-minicart-count]",
5405
+ "[data-cart-count]",
5406
+ ".cart-count",
5407
+ ".minicart__count",
5408
+ "[class*='cart-count']",
5409
+ "[class*='CartCount']",
5410
+ "[aria-label*='itens' i][role='status']",
5411
+ "header [data-minicart-trigger] [class*='badge']",
5412
+ "header [aria-label*='cart' i] [class*='count']",
5413
+ "header [aria-label*='sacola' i] [class*='badge']",
5414
+ "header [aria-label*='sacola' i] [class*='count']",
5415
+ "header a[href*='/cart'] [class*='count']",
5416
+ "header a[href*='/checkout'] [class*='count']",
5417
+ "[data-fs-cart-icon] + [class*='count']",
5418
+ "[class*='Minicart'] [class*='count']",
5419
+ "[class*='minicart'] [class*='count']"
5161
5420
  ]
5162
5421
  };
5163
5422
  function selectorsFor(key, ctxOrRc = {}) {
@@ -5182,7 +5441,18 @@ function isResolutionContext(v) {
5182
5441
  }
5183
5442
 
5184
5443
  // src/engine/flows.ts
5185
- var PURCHASE_JOURNEY_TOTAL_STEPS = 8;
5444
+ async function screenshotStable(page, opts) {
5445
+ await Promise.race([
5446
+ stabilizeCarousels(page).catch(() => {
5447
+ return;
5448
+ }),
5449
+ new Promise((resolve) => setTimeout(resolve, 3000))
5450
+ ]);
5451
+ await page.screenshot({ path: opts.path, fullPage: opts.fullPage ?? false }).catch(() => {
5452
+ return;
5453
+ });
5454
+ }
5455
+ var PURCHASE_JOURNEY_TOTAL_STEPS = 9;
5186
5456
  var DEBUG_PARITY2 = process.env.DEBUG_PARITY === "1" || process.env.DEBUG_PARITY === "true";
5187
5457
  var DEBUG_START2 = Date.now();
5188
5458
  function dlog2(ctx, msg) {
@@ -5192,6 +5462,41 @@ function dlog2(ctx, msg) {
5192
5462
  process.stderr.write(`[+${elapsed}s ${ctx.viewport}/${ctx.side}] ${msg}
5193
5463
  `);
5194
5464
  }
5465
+ function withCap(p, capMs, fallback) {
5466
+ return Promise.race([
5467
+ p.catch(() => fallback),
5468
+ new Promise((resolve) => setTimeout(() => resolve(fallback), capMs))
5469
+ ]);
5470
+ }
5471
+ var VARIANT_REQUIRED_TEXT_PATTERNS = [
5472
+ /selecione um produto/i,
5473
+ /selecione um tamanho/i,
5474
+ /selecione uma cor/i,
5475
+ /selecione uma op[cç][aã]o/i,
5476
+ /select a size/i,
5477
+ /select a color/i,
5478
+ /select an option/i,
5479
+ /choose an option/i,
5480
+ /please select/i,
5481
+ /select size/i,
5482
+ /select color/i
5483
+ ];
5484
+ var ADD_TO_CART_ERROR_PATTERNS = [
5485
+ ...VARIANT_REQUIRED_TEXT_PATTERNS,
5486
+ /estoque esgotado/i,
5487
+ /out of stock/i,
5488
+ /indispon[ií]vel/i,
5489
+ /unavailable/i
5490
+ ];
5491
+ var ADD_TO_CART_SUCCESS_PATTERNS = [
5492
+ /produto adicionado/i,
5493
+ /adicionado ao carrinho/i,
5494
+ /adicionado [aà]\s+sacola/i,
5495
+ /added to cart/i,
5496
+ /added to bag/i,
5497
+ /item added/i,
5498
+ /successfully added/i
5499
+ ];
5195
5500
  function selFor(ctx, key) {
5196
5501
  return selectorsFor(key, { rc: ctx.rc, learned: ctx.learned, platform: ctx.platform });
5197
5502
  }
@@ -5199,7 +5504,7 @@ var FLOW_DEADLINE_MS = {
5199
5504
  homepage: 90000,
5200
5505
  plp: 180000,
5201
5506
  pdp: 240000,
5202
- "purchase-journey": 360000
5507
+ "purchase-journey": 420000
5203
5508
  };
5204
5509
  async function runFlow(flow, ctx) {
5205
5510
  const start = Date.now();
@@ -5236,7 +5541,7 @@ async function runFlow(flow, ctx) {
5236
5541
  status: "failed",
5237
5542
  durationMs: deadlineMs,
5238
5543
  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.`
5544
+ 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
5545
  }
5241
5546
  ], start));
5242
5547
  const CLOSE_CAP_MS = 5000;
@@ -5244,7 +5549,7 @@ async function runFlow(flow, ctx) {
5244
5549
  p.close().catch(() => {
5245
5550
  return;
5246
5551
  }),
5247
- new Promise((resolve2) => setTimeout(resolve2, CLOSE_CAP_MS))
5552
+ new Promise((resolveClose) => setTimeout(resolveClose, CLOSE_CAP_MS))
5248
5553
  ]);
5249
5554
  cleanup = Promise.allSettled(pages.map(cappedClose));
5250
5555
  }, deadlineMs);
@@ -5352,7 +5657,7 @@ async function flowPurchaseJourney(ctx) {
5352
5657
  const pages = [];
5353
5658
  const steps = [];
5354
5659
  const page = await ctx.ctx.newPage();
5355
- let recoveryBudget = ctx.recoveryBudget ?? 3;
5660
+ const budget = { remaining: ctx.recoveryBudget ?? 5 };
5356
5661
  const total = PURCHASE_JOURNEY_TOTAL_STEPS;
5357
5662
  const reportStart = (idx, name) => {
5358
5663
  ctx.onStep?.({ phase: "start", name, index: idx, total });
@@ -5362,15 +5667,12 @@ async function flowPurchaseJourney(ctx) {
5362
5667
  };
5363
5668
  try {
5364
5669
  reportStart(1, "visit-home");
5365
- dlog2(ctx, `step 1 visit-home: capturePage(${ctx.baseUrl}) start (scrollToLoad=false)`);
5366
5670
  const homeCap = await capturePage(page, {
5367
5671
  url: ctx.baseUrl,
5368
5672
  side: ctx.side,
5369
5673
  viewport: ctx.viewport,
5370
- screenshotPath: screenshotPath(ctx, "pj-1-home"),
5371
- scrollToLoad: false
5674
+ screenshotPath: screenshotPath(ctx, "pj-1-home")
5372
5675
  });
5373
- dlog2(ctx, `step 1 visit-home: capturePage done status=${homeCap.status}`);
5374
5676
  pages.push(homeCap);
5375
5677
  const step1Status = homeCap.status >= 200 && homeCap.status < 400 ? "ok" : "failed";
5376
5678
  steps.push({
@@ -5389,24 +5691,19 @@ async function flowPurchaseJourney(ctx) {
5389
5691
  return { pages, steps };
5390
5692
  }
5391
5693
  reportStart(2, "navigate-plp");
5392
- dlog2(ctx, `step 2 navigate-plp: findCategoryUrl start (hint=${ctx.rc.plpUrlHint ?? "—"})`);
5393
5694
  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
5695
  if (!plpHit) {
5396
5696
  steps.push(makeSkipStep(2, "navigate-plp", ctx, "no category link found"));
5397
5697
  reportEnd(2, "navigate-plp", "skipped", 0, "no category link found");
5398
5698
  return { pages, steps };
5399
5699
  }
5400
5700
  const t2 = Date.now();
5401
- dlog2(ctx, `step 2 navigate-plp: capturePage(${plpHit.url}) start (scrollToLoad=false)`);
5402
5701
  const plpCap = await capturePage(page, {
5403
5702
  url: plpHit.url,
5404
5703
  side: ctx.side,
5405
5704
  viewport: ctx.viewport,
5406
- screenshotPath: screenshotPath(ctx, "pj-2-plp"),
5407
- scrollToLoad: false
5705
+ screenshotPath: screenshotPath(ctx, "pj-2-plp")
5408
5706
  });
5409
- dlog2(ctx, `step 2 navigate-plp: capturePage done status=${plpCap.status}`);
5410
5707
  pages.push(plpCap);
5411
5708
  const step2Status = plpCap.status >= 200 && plpCap.status < 400 ? "ok" : "failed";
5412
5709
  steps.push({
@@ -5425,9 +5722,35 @@ async function flowPurchaseJourney(ctx) {
5425
5722
  steps[steps.length - 1].actionDescription = `Navegou pra categoria \`${plpHit.url}\` (via \`${plpHit.selector}\`)`;
5426
5723
  steps[steps.length - 1].beforeUrl = ctx.baseUrl;
5427
5724
  reportStart(3, "enter-pdp");
5428
- const pdpHit = await findProductUrl(page, ctx);
5725
+ let pdpHit = await findProductUrl(page, ctx);
5726
+ let pdpRecoveredByLlm = false;
5727
+ if (!pdpHit && budget.remaining > 0) {
5728
+ const html = await page.content().catch(() => "");
5729
+ if (html) {
5730
+ const suggestion = await suggestRecovery({
5731
+ stepName: "enter-pdp",
5732
+ intendedAction: "Encontrar um link <a> que leve para a página de detalhes (PDP) de algum produto listado na PLP atual",
5733
+ html,
5734
+ alreadyTried: selFor(ctx, "productCard")
5735
+ });
5736
+ if (suggestion) {
5737
+ budget.remaining--;
5738
+ try {
5739
+ const el = page.locator(suggestion.selector).first();
5740
+ if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
5741
+ const href = await el.getAttribute("href");
5742
+ if (href) {
5743
+ const abs = new URL(href, page.url()).toString();
5744
+ pdpHit = { url: abs, selector: suggestion.selector };
5745
+ pdpRecoveredByLlm = true;
5746
+ }
5747
+ }
5748
+ } catch {}
5749
+ }
5750
+ }
5751
+ }
5429
5752
  if (!pdpHit) {
5430
- steps.push(makeSkipStep(3, "enter-pdp", ctx, "no product card found"));
5753
+ steps.push(makeSkipStep(3, "enter-pdp", ctx, "no product card found (recovery exhausted)"));
5431
5754
  reportEnd(3, "enter-pdp", "skipped", 0, "no product card found");
5432
5755
  return { pages, steps };
5433
5756
  }
@@ -5436,8 +5759,7 @@ async function flowPurchaseJourney(ctx) {
5436
5759
  url: pdpHit.url,
5437
5760
  side: ctx.side,
5438
5761
  viewport: ctx.viewport,
5439
- screenshotPath: screenshotPath(ctx, "pj-3-pdp"),
5440
- scrollToLoad: false
5762
+ screenshotPath: screenshotPath(ctx, "pj-3-pdp")
5441
5763
  });
5442
5764
  pages.push(pdpCap);
5443
5765
  const step3Status = pdpCap.status >= 200 && pdpCap.status < 400 ? "ok" : "failed";
@@ -5451,10 +5773,11 @@ async function flowPurchaseJourney(ctx) {
5451
5773
  url: pdpCap.finalUrl,
5452
5774
  screenshotPath: pdpCap.screenshotPath,
5453
5775
  selectorKey: "productCard",
5454
- usedSelector: pdpHit.selector
5776
+ usedSelector: pdpHit.selector,
5777
+ recoveredByLlm: pdpRecoveredByLlm || undefined
5455
5778
  });
5456
5779
  reportEnd(3, "enter-pdp", step3Status, Date.now() - t3);
5457
- steps[steps.length - 1].actionDescription = `Abriu PDP \`${pdpHit.url}\` (via \`${pdpHit.selector}\`)`;
5780
+ steps[steps.length - 1].actionDescription = `Abriu PDP \`${pdpHit.url}\` (via \`${pdpHit.selector}\`${pdpRecoveredByLlm ? " — recovery LLM" : ""})`;
5458
5781
  steps[steps.length - 1].beforeUrl = plpHit.url;
5459
5782
  const expectedProductTitle = await extractProductTitle(page);
5460
5783
  dlog2(ctx, `step 3 enter-pdp: extracted product title → ${expectedProductTitle ? `"${expectedProductTitle.slice(0, 60)}"` : "null"}`);
@@ -5464,402 +5787,445 @@ async function flowPurchaseJourney(ctx) {
5464
5787
  productTitle: expectedProductTitle
5465
5788
  };
5466
5789
  }
5467
- reportStart(4, "shipping-calc-pdp");
5790
+ reportStart(4, "select-variant");
5791
+ const t4 = Date.now();
5792
+ const beforeUrl4 = page.url();
5793
+ const spBefore4 = screenshotPath(ctx, "pj-4-select-variant-before");
5794
+ await screenshotStable(page, { path: spBefore4, fullPage: false });
5795
+ const variantResult = await selectVariant(page, ctx);
5796
+ let variantLlmAction = null;
5797
+ if (variantResult.actions.length === 0 && variantResult.variantRequired && budget.remaining > 0) {
5798
+ variantLlmAction = await attemptStepAction({
5799
+ page,
5800
+ ctx,
5801
+ stepName: "select-variant",
5802
+ 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.",
5803
+ selectorKey: "quantityIncrement",
5804
+ action: "click",
5805
+ recoveryBudget: budget
5806
+ });
5807
+ }
5808
+ const sp4 = screenshotPath(ctx, "pj-4-select-variant");
5809
+ await screenshotStable(page, { path: sp4, fullPage: false });
5810
+ if (variantResult.actions.length > 0 || variantLlmAction?.performed) {
5811
+ const llmDesc = variantLlmAction?.performed ? `Recovery LLM: ${variantLlmAction.action} em \`${variantLlmAction.selector}\`` : null;
5812
+ const desc = variantResult.actions.length > 0 ? variantResult.actions.join("; ") : llmDesc ?? "(variante selecionada)";
5813
+ steps.push({
5814
+ step: 4,
5815
+ name: "select-variant",
5816
+ side: ctx.side,
5817
+ viewport: ctx.viewport,
5818
+ status: "ok",
5819
+ durationMs: Date.now() - t4,
5820
+ url: page.url(),
5821
+ screenshotPath: sp4,
5822
+ screenshotBeforePath: spBefore4,
5823
+ beforeUrl: beforeUrl4,
5824
+ actionDescription: llmDesc && variantResult.actions.length > 0 ? `${desc}; ${llmDesc}` : desc,
5825
+ selectorKey: variantLlmAction?.performed ? "quantityIncrement" : variantResult.primarySelectorKey,
5826
+ usedSelector: variantLlmAction?.performed ? variantLlmAction.selector : variantResult.primarySelector,
5827
+ recoveredByLlm: variantLlmAction?.recoveredByLlm || undefined,
5828
+ detail: { actions: variantResult.actions, llmRecovery: !!variantLlmAction?.performed }
5829
+ });
5830
+ reportEnd(4, "select-variant", "ok", Date.now() - t4);
5831
+ } else {
5832
+ 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)";
5833
+ steps.push(makeSkipStep(4, "select-variant", ctx, skipNote));
5834
+ reportEnd(4, "select-variant", "skipped", 0, skipNote);
5835
+ }
5836
+ reportStart(5, "shipping-calc-pdp");
5468
5837
  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--;
5838
+ let cepPdpRecoveredByLlm = false;
5839
+ if (!cepInputPdp && budget.remaining > 0) {
5840
+ const html = await page.content().catch(() => "");
5841
+ if (html) {
5842
+ const suggestion = await suggestRecovery({
5843
+ stepName: "shipping-calc-pdp",
5844
+ 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.",
5845
+ html,
5846
+ alreadyTried: selFor(ctx, "cepInputPdp")
5847
+ });
5848
+ if (suggestion) {
5849
+ budget.remaining--;
5850
+ const el = page.locator(suggestion.selector).first();
5851
+ if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
5852
+ cepInputPdp = suggestion.selector;
5853
+ cepPdpRecoveredByLlm = true;
5854
+ }
5855
+ }
5476
5856
  }
5477
5857
  }
5478
5858
  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(() => {
5483
- return;
5484
- });
5859
+ const t5 = Date.now();
5860
+ const beforeUrl5 = page.url();
5861
+ const spBefore5 = screenshotPath(ctx, "pj-5-shipping-pdp-before");
5862
+ await screenshotStable(page, { path: spBefore5, fullPage: false });
5485
5863
  const ok = await fillCep(page, cepInputPdp, ctx.rc.cep);
5486
- const sp = screenshotPath(ctx, "pj-4-shipping-pdp");
5487
- await page.screenshot({ path: sp, fullPage: false }).catch(() => {
5488
- return;
5489
- });
5490
- const step4Status = ok ? "ok" : "failed";
5864
+ const sp = screenshotPath(ctx, "pj-5-shipping-pdp");
5865
+ await screenshotStable(page, { path: sp, fullPage: false });
5866
+ const step5Status = ok ? "ok" : "failed";
5491
5867
  steps.push({
5492
- step: 4,
5868
+ step: 5,
5493
5869
  name: "shipping-calc-pdp",
5494
5870
  side: ctx.side,
5495
5871
  viewport: ctx.viewport,
5496
- status: step4Status,
5497
- durationMs: Date.now() - t4,
5872
+ status: step5Status,
5873
+ durationMs: Date.now() - t5,
5498
5874
  url: page.url(),
5499
5875
  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)" : ""}`,
5876
+ screenshotBeforePath: spBefore5,
5877
+ beforeUrl: beforeUrl5,
5878
+ actionDescription: `Preencheu CEP '${ctx.rc.cep}' no input \`${cepInputPdp}\`${cepPdpRecoveredByLlm ? " (via recovery LLM)" : ""}`,
5503
5879
  detail: { cepUsed: ctx.rc.cep },
5504
5880
  selectorKey: "cepInputPdp",
5505
5881
  usedSelector: cepInputPdp,
5506
- recoveredByLlm: cepPdpRecovered || undefined
5882
+ recoveredByLlm: cepPdpRecoveredByLlm || undefined
5507
5883
  });
5508
- reportEnd(4, "shipping-calc-pdp", step4Status, Date.now() - t4);
5884
+ reportEnd(5, "shipping-calc-pdp", step5Status, Date.now() - t5);
5509
5885
  } 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");
5886
+ steps.push(makeSkipStep(5, "shipping-calc-pdp", ctx, "no CEP input on PDP (recovery exhausted)"));
5887
+ reportEnd(5, "shipping-calc-pdp", "skipped", 0, "no CEP input on PDP");
5512
5888
  }
5513
- reportStart(5, "add-to-cart");
5889
+ reportStart(6, "add-to-cart");
5514
5890
  let buyHit = await firstVisibleLocator(page, selFor(ctx, "buyButton"));
5515
5891
  let buyRecovered = false;
5516
- if (!buyHit && recoveryBudget > 0) {
5892
+ if (!buyHit && budget.remaining > 0) {
5517
5893
  const recovery = await attemptRecovery(page, ctx, "add-to-cart", "Clicar no botão de comprar/adicionar ao carrinho", selFor(ctx, "buyButton"));
5518
5894
  if (recovery) {
5519
5895
  buyHit = recovery;
5520
5896
  buyRecovered = true;
5521
- recoveryBudget--;
5897
+ budget.remaining--;
5522
5898
  }
5523
5899
  }
5524
5900
  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");
5901
+ steps.push(makeSkipStep(6, "add-to-cart", ctx, "no buy button found (recovery exhausted)"));
5902
+ reportEnd(6, "add-to-cart", "skipped", 0, "no buy button found");
5527
5903
  return { pages, steps };
5528
5904
  }
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(() => {
5533
- return;
5534
- });
5905
+ const t6 = Date.now();
5906
+ const beforeUrl6 = page.url();
5907
+ const spBefore6 = screenshotPath(ctx, "pj-6-add-cart-before");
5908
+ await screenshotStable(page, { path: spBefore6, fullPage: false });
5909
+ const cartCountBefore = await readCartCount(page, ctx);
5535
5910
  const buyText = await buyHit.locator.innerText().catch(() => "");
5536
5911
  await buyHit.locator.click({ timeout: 5000 }).catch(() => {
5537
5912
  return;
5538
5913
  });
5539
- await page.waitForTimeout(2000);
5540
- const sp5 = screenshotPath(ctx, "pj-5-add-cart");
5541
- await page.screenshot({ path: sp5, fullPage: false }).catch(() => {
5542
- return;
5543
- });
5914
+ let validation = await validateAddToCart(page, ctx, cartCountBefore, beforeUrl6);
5915
+ let variantRetryNote;
5916
+ if (validation.status === "failed" && validation.errorText && VARIANT_REQUIRED_TEXT_PATTERNS.some((re) => re.test(validation.errorText))) {
5917
+ const retryHit = await findAndIncrementZeroQtyInput(page, ctx);
5918
+ if (retryHit) {
5919
+ variantRetryNote = `Retry: ${retryHit.description}`;
5920
+ } else if (budget.remaining > 0) {
5921
+ const llmRetry = await attemptStepAction({
5922
+ page,
5923
+ ctx,
5924
+ stepName: "add-to-cart-retry-variant",
5925
+ 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.",
5926
+ selectorKey: "quantityIncrement",
5927
+ action: "click",
5928
+ recoveryBudget: budget
5929
+ });
5930
+ if (llmRetry.performed) {
5931
+ variantRetryNote = `Retry via LLM: ${llmRetry.action} em \`${llmRetry.selector}\``;
5932
+ }
5933
+ }
5934
+ if (variantRetryNote) {
5935
+ await page.waitForTimeout(500);
5936
+ await buyHit.locator.click({ timeout: 5000 }).catch(() => {
5937
+ return;
5938
+ });
5939
+ validation = await validateAddToCart(page, ctx, cartCountBefore, beforeUrl6);
5940
+ }
5941
+ }
5942
+ const sp6 = screenshotPath(ctx, "pj-6-add-cart");
5943
+ await screenshotStable(page, { path: sp6, fullPage: false });
5944
+ const buyActionDesc = `Clicou no botão${buyText ? ` '${buyText.slice(0, 40).trim()}'` : ""} (\`${buyHit.selector}\`)${buyRecovered ? " — selector veio de recovery LLM" : ""}`;
5945
+ const fullActionDesc = variantRetryNote ? `${buyActionDesc} — ${variantRetryNote} — ${validation.note}` : `${buyActionDesc} — ${validation.note}`;
5544
5946
  steps.push({
5545
- step: 5,
5947
+ step: 6,
5546
5948
  name: "add-to-cart",
5547
5949
  side: ctx.side,
5548
5950
  viewport: ctx.viewport,
5549
- status: "ok",
5550
- durationMs: Date.now() - t5,
5951
+ status: validation.status,
5952
+ durationMs: Date.now() - t6,
5551
5953
  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" : ""}`,
5954
+ screenshotPath: sp6,
5955
+ screenshotBeforePath: spBefore6,
5956
+ beforeUrl: beforeUrl6,
5957
+ actionDescription: fullActionDesc,
5556
5958
  selectorKey: "buyButton",
5557
5959
  usedSelector: buyHit.selector,
5558
- recoveredByLlm: buyRecovered || undefined
5559
- });
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(() => {
5566
- return;
5960
+ recoveredByLlm: buyRecovered || undefined,
5961
+ note: validation.status === "ok" ? undefined : validation.note,
5962
+ detail: { signal: validation.signal, errorText: validation.errorText, variantRetry: variantRetryNote }
5567
5963
  });
5964
+ reportEnd(6, "add-to-cart", validation.status, Date.now() - t6, validation.status === "ok" ? undefined : validation.note);
5965
+ if (validation.status === "failed") {
5966
+ return { pages, steps };
5967
+ }
5968
+ reportStart(7, "open-minicart");
5969
+ const t7 = Date.now();
5970
+ const beforeUrl7 = page.url();
5971
+ const spBefore7 = screenshotPath(ctx, "pj-7-minicart-before");
5972
+ await screenshotStable(page, { path: spBefore7, fullPage: false });
5568
5973
  let miniHit = await firstVisibleLocator(page, selFor(ctx, "minicartTrigger"));
5569
5974
  let miniRecovered = false;
5570
- if (!miniHit && recoveryBudget > 0) {
5975
+ if (!miniHit && budget.remaining > 0) {
5571
5976
  const recovery = await attemptRecovery(page, ctx, "open-minicart", "Abrir o minicart/drawer do carrinho", selFor(ctx, "minicartTrigger"));
5572
5977
  if (recovery) {
5573
5978
  miniHit = recovery;
5574
5979
  miniRecovered = true;
5575
- recoveryBudget--;
5980
+ budget.remaining--;
5576
5981
  }
5577
5982
  }
5578
5983
  let cartOpenMethod = "failed";
5579
5984
  let miniText = "";
5985
+ let cartRevealMode = "unknown";
5986
+ const drawerAlreadyOpen = await isCartRevealed(page, expectedProductTitle) !== null;
5580
5987
  if (miniHit) {
5581
5988
  miniText = await miniHit.locator.innerText().catch(() => "");
5989
+ cartRevealMode = await detectCartRevealMode(page, miniHit.locator, drawerAlreadyOpen, ctx.viewport).catch(() => "unknown");
5990
+ dlog2(ctx, `step 7 open-minicart: cartRevealMode=${cartRevealMode}`);
5582
5991
  const openResult = await openMinicart(page, miniHit, ctx, expectedProductTitle);
5583
5992
  cartOpenMethod = openResult.method;
5584
5993
  } else {
5585
- const alreadyOpen = await isCartRevealed(page, expectedProductTitle);
5586
- if (alreadyOpen) {
5994
+ if (drawerAlreadyOpen) {
5587
5995
  cartOpenMethod = "already-open";
5588
- dlog2(ctx, `step 6 open-minicart: no trigger, but drawer already visible (matched ${alreadyOpen})`);
5996
+ cartRevealMode = "inline-notification";
5997
+ dlog2(ctx, "step 7 open-minicart: no trigger, drawer already visible");
5589
5998
  }
5590
5999
  }
5591
- let step6Validation;
6000
+ let step7Validation;
5592
6001
  if (expectedProductTitle && cartOpenMethod !== "failed") {
5593
6002
  const v = await validateCartContainsTitle(page, expectedProductTitle, ctx);
5594
6003
  let reasonText;
5595
6004
  if (!v.found) {
5596
6005
  if (v.observedTitles.length === 0) {
5597
6006
  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)";
6007
+ reasonText = emptyBanner ? `cart genuinely empty — add-to-cart didn't persist. Banner: "${emptyBanner}"` : "no cart line items visible (selectors may not match markup)";
5599
6008
  } else {
5600
6009
  reasonText = `expected title not found among ${v.observedTitles.length} observed`;
5601
6010
  }
5602
6011
  }
5603
- step6Validation = {
6012
+ step7Validation = {
5604
6013
  expectedTitle: expectedProductTitle,
5605
6014
  found: v.found,
5606
6015
  method: v.method,
5607
6016
  observedTitles: v.observedTitles.slice(0, 8),
5608
6017
  reason: reasonText
5609
6018
  };
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");
5615
- }
5616
- const sp6 = screenshotPath(ctx, "pj-6-minicart");
5617
- await page.screenshot({ path: sp6, fullPage: false }).catch(() => {
5618
- return;
5619
- });
5620
- const step6Status = cartOpenMethod === "failed" ? "failed" : step6Validation && !step6Validation.found ? "failed" : "ok";
6019
+ dlog2(ctx, `step 7 open-minicart: validation → found=${v.found} (${v.method})${reasonText ? ` — ${reasonText.slice(0, 80)}` : ""}`);
6020
+ }
6021
+ const sp7 = screenshotPath(ctx, "pj-7-minicart");
6022
+ await screenshotStable(page, { path: sp7, fullPage: false });
6023
+ const cartEmptyReason = step7Validation?.reason ?? "";
6024
+ const isProdCartEmptyQuirk = ctx.acceptProdQuirks === true && ctx.side === "prod" && step7Validation !== undefined && !step7Validation.found && cartEmptyReason.startsWith("cart genuinely empty");
6025
+ const step7Status = isProdCartEmptyQuirk ? "skipped" : cartOpenMethod === "failed" ? "failed" : step7Validation && !step7Validation.found ? "failed" : "ok";
6026
+ const step7QuirkNote = isProdCartEmptyQuirk ? "cart-empty-prod-quirk: aceito via --accept-prod-quirks (cartRevealMode validated by separate check)" : undefined;
5621
6027
  steps.push({
5622
- step: 6,
6028
+ step: 7,
5623
6029
  name: "open-minicart",
5624
6030
  side: ctx.side,
5625
6031
  viewport: ctx.viewport,
5626
- status: step6Status,
5627
- durationMs: Date.now() - t6,
6032
+ status: step7Status,
6033
+ durationMs: Date.now() - t7,
5628
6034
  url: page.url(),
5629
- screenshotPath: sp6,
5630
- screenshotBeforePath: spBefore6,
5631
- beforeUrl: beforeUrl6,
6035
+ screenshotPath: sp7,
6036
+ screenshotBeforePath: spBefore7,
6037
+ beforeUrl: beforeUrl7,
5632
6038
  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",
6039
+ cartRevealMode,
6040
+ cartValidation: step7Validation,
6041
+ note: step7QuirkNote,
6042
+ 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
6043
  selectorKey: miniHit ? "minicartTrigger" : undefined,
5636
6044
  usedSelector: miniHit?.selector,
5637
6045
  recoveredByLlm: miniRecovered || undefined
5638
6046
  });
5639
- reportEnd(6, "open-minicart", step6Status, Date.now() - t6);
5640
- reportStart(7, "shipping-calc-cart");
6047
+ reportEnd(7, "open-minicart", step7Status, Date.now() - t7, step7QuirkNote);
6048
+ if (isProdCartEmptyQuirk) {
6049
+ const quirkNote = "cart-empty-prod-quirk: skipped (depende do cart que prod não persistiu)";
6050
+ reportStart(8, "shipping-calc-cart");
6051
+ steps.push(makeSkipStep(8, "shipping-calc-cart", ctx, quirkNote));
6052
+ reportEnd(8, "shipping-calc-cart", "skipped", 0, quirkNote);
6053
+ reportStart(9, "go-checkout");
6054
+ steps.push(makeSkipStep(9, "go-checkout", ctx, quirkNote));
6055
+ reportEnd(9, "go-checkout", "skipped", 0, quirkNote);
6056
+ return { pages, steps };
6057
+ }
6058
+ reportStart(8, "shipping-calc-cart");
5641
6059
  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--;
6060
+ let cepCartRecoveredByLlm = false;
6061
+ if (!cepInputCart && budget.remaining > 0) {
6062
+ const html = await page.content().catch(() => "");
6063
+ if (html) {
6064
+ const suggestion = await suggestRecovery({
6065
+ stepName: "shipping-calc-cart",
6066
+ 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.",
6067
+ html,
6068
+ alreadyTried: selFor(ctx, "cepInputCart")
6069
+ });
6070
+ if (suggestion) {
6071
+ budget.remaining--;
6072
+ const el = page.locator(suggestion.selector).first();
6073
+ if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
6074
+ cepInputCart = suggestion.selector;
6075
+ cepCartRecoveredByLlm = true;
6076
+ }
6077
+ }
5649
6078
  }
5650
6079
  }
5651
6080
  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(() => {
5656
- return;
5657
- });
6081
+ const t8 = Date.now();
6082
+ const beforeUrl8 = page.url();
6083
+ const spBefore8 = screenshotPath(ctx, "pj-8-shipping-cart-before");
6084
+ await screenshotStable(page, { path: spBefore8, fullPage: false });
5658
6085
  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(() => {
5661
- return;
5662
- });
5663
- const step7Status = ok ? "ok" : "failed";
6086
+ const sp8 = screenshotPath(ctx, "pj-8-shipping-cart");
6087
+ await screenshotStable(page, { path: sp8, fullPage: false });
6088
+ const step8Status = ok ? "ok" : "failed";
5664
6089
  steps.push({
5665
- step: 7,
6090
+ step: 8,
5666
6091
  name: "shipping-calc-cart",
5667
6092
  side: ctx.side,
5668
6093
  viewport: ctx.viewport,
5669
- status: step7Status,
5670
- durationMs: Date.now() - t7,
6094
+ status: step8Status,
6095
+ durationMs: Date.now() - t8,
5671
6096
  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)" : ""}`,
6097
+ screenshotPath: sp8,
6098
+ screenshotBeforePath: spBefore8,
6099
+ beforeUrl: beforeUrl8,
6100
+ actionDescription: `Preencheu CEP '${ctx.rc.cep}' no carrinho (\`${cepInputCart}\`)${cepCartRecoveredByLlm ? " recovery LLM" : ""}`,
5676
6101
  detail: { cepUsed: ctx.rc.cep },
5677
6102
  selectorKey: "cepInputCart",
5678
6103
  usedSelector: cepInputCart,
5679
- recoveredByLlm: cepCartRecovered || undefined
6104
+ recoveredByLlm: cepCartRecoveredByLlm || undefined
5680
6105
  });
5681
- reportEnd(7, "shipping-calc-cart", step7Status, Date.now() - t7);
6106
+ reportEnd(8, "shipping-calc-cart", step8Status, Date.now() - t8);
5682
6107
  } 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})`);
6108
+ steps.push(makeSkipStep(8, "shipping-calc-cart", ctx, "no CEP input in cart (recovery exhausted)"));
6109
+ reportEnd(8, "shipping-calc-cart", "skipped", 0, "no CEP input in cart");
6110
+ }
6111
+ reportStart(9, "go-checkout");
6112
+ const t9 = Date.now();
6113
+ const beforeUrl9 = page.url();
6114
+ if (/\/(checkout|carrinho)(\/|$|\?)/i.test(beforeUrl9)) {
6115
+ await waitForCartHydration(page);
6116
+ let step9EarlyValidation;
6117
+ if (expectedProductTitle) {
6118
+ const v = await validateCartContainsTitle(page, expectedProductTitle, ctx);
6119
+ step9EarlyValidation = {
6120
+ expectedTitle: expectedProductTitle,
6121
+ found: v.found,
6122
+ method: v.method,
6123
+ observedTitles: v.observedTitles.slice(0, 8),
6124
+ reason: v.found ? undefined : `expected title not found among ${v.observedTitles.length} observed on checkout`
6125
+ };
5714
6126
  }
6127
+ const sp9early = screenshotPath(ctx, "pj-9-checkout-reached");
6128
+ await screenshotStable(page, { path: sp9early, fullPage: false });
6129
+ const earlyStatus = step9EarlyValidation && !step9EarlyValidation.found ? "failed" : "ok";
6130
+ steps.push({
6131
+ step: 9,
6132
+ name: "go-checkout",
6133
+ side: ctx.side,
6134
+ viewport: ctx.viewport,
6135
+ status: earlyStatus,
6136
+ durationMs: Date.now() - t9,
6137
+ url: page.url(),
6138
+ screenshotPath: sp9early,
6139
+ cartValidation: step9EarlyValidation,
6140
+ 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)` : ""}`,
6141
+ detail: { reachedVia: "minicart-direct-link" }
6142
+ });
6143
+ reportEnd(9, "go-checkout", earlyStatus, Date.now() - t9);
6144
+ return { pages, steps };
5715
6145
  }
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"}`);
6146
+ let checkoutHit = await firstVisibleLocator(page, selFor(ctx, "checkoutButton"));
5746
6147
  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"}`);
6148
+ if (!checkoutHit && budget.remaining > 0) {
6149
+ const recovery = await attemptRecovery(page, ctx, "go-checkout", "Clicar no botão 'Finalizar compra' / 'Ir para o checkout' / 'Finalizar'", selFor(ctx, "checkoutButton"));
5752
6150
  if (recovery) {
5753
6151
  checkoutHit = recovery;
5754
6152
  checkoutRecovered = true;
5755
- recoveryBudget--;
6153
+ budget.remaining--;
5756
6154
  }
5757
6155
  }
5758
6156
  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");
6157
+ steps.push(makeSkipStep(9, "go-checkout", ctx, "no checkout button found (recovery exhausted)"));
6158
+ reportEnd(9, "go-checkout", "skipped", 0, "no checkout button found");
5762
6159
  return { pages, steps };
5763
6160
  }
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;
6161
+ const spBefore9 = screenshotPath(ctx, "pj-9-checkout-before");
6162
+ await screenshotStable(page, { path: spBefore9, fullPage: false });
6163
+ let usedSelector = checkoutHit.selector;
6164
+ let clickedText = await checkoutHit.locator.innerText().catch(() => "");
6165
+ let reachedCheckout = false;
6166
+ let attempt = 0;
6167
+ const triedSelectors = new Set([usedSelector]);
6168
+ while (attempt < 3) {
6169
+ attempt++;
5772
6170
  await Promise.all([
5773
- page.waitForURL(urlChangePredicate, { timeout: 1e4 }).catch(() => {
6171
+ page.waitForURL(/\/(checkout|carrinho)/i, { timeout: 1e4 }).catch(() => {
5774
6172
  return;
5775
6173
  }),
5776
- hit.locator.click({ timeout: 5000 }).catch(() => {
6174
+ checkoutHit.locator.click({ timeout: 5000 }).catch(() => {
5777
6175
  return;
5778
6176
  })
5779
6177
  ]);
5780
6178
  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);
6179
+ reachedCheckout = /\/(checkout|carrinho)(\/|$|\?)/i.test(page.url());
6180
+ if (reachedCheckout)
6181
+ break;
6182
+ if (budget.remaining <= 0)
6183
+ break;
6184
+ dlog2(ctx, `step 9 go-checkout: click on \`${usedSelector}\` didn't navigate (attempt ${attempt}). URL still ${page.url()}. Asking LLM for another selector.`);
6185
+ const triedList = Array.from(triedSelectors);
6186
+ 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);
6187
+ if (!recovery)
6188
+ break;
6189
+ checkoutHit = recovery;
6190
+ checkoutRecovered = true;
6191
+ usedSelector = recovery.selector;
6192
+ triedSelectors.add(usedSelector);
6193
+ clickedText = await recovery.locator.innerText().catch(() => "");
6194
+ budget.remaining--;
6195
+ }
6196
+ let step9Validation;
6197
+ if (reachedCheckout && expectedProductTitle) {
6198
+ await waitForCartHydration(page);
5825
6199
  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 = {
6200
+ step9Validation = {
5836
6201
  expectedTitle: expectedProductTitle,
5837
6202
  found: v.found,
5838
6203
  method: v.method,
5839
6204
  observedTitles: v.observedTitles.slice(0, 8),
5840
- reason: reasonText
6205
+ reason: v.found ? undefined : `expected title not found among ${v.observedTitles.length} observed on checkout`
5841
6206
  };
5842
- dlog2(ctx, `step 8 go-checkout: checkout validation → found=${v.found} (${v.method})${reasonText ? ` — ${reasonText.slice(0, 80)}` : ""}`);
5843
6207
  }
5844
- const step8Status = reachedCheckout ? step8Validation && !step8Validation.found ? "failed" : "ok" : "failed";
6208
+ const sp9 = screenshotPath(ctx, "pj-9-checkout-reached");
6209
+ await screenshotStable(page, { path: sp9, fullPage: false });
6210
+ const step9Status = !reachedCheckout ? "failed" : step9Validation && !step9Validation.found ? "failed" : "ok";
5845
6211
  steps.push({
5846
- step: 8,
6212
+ step: 9,
5847
6213
  name: "go-checkout",
5848
6214
  side: ctx.side,
5849
6215
  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)` : ""}`,
6216
+ status: step9Status,
6217
+ durationMs: Date.now() - t9,
6218
+ url: page.url(),
6219
+ screenshotPath: sp9,
6220
+ screenshotBeforePath: spBefore9,
6221
+ beforeUrl: beforeUrl9,
6222
+ cartValidation: step9Validation,
6223
+ 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
6224
  selectorKey: "checkoutButton",
5859
6225
  usedSelector,
5860
6226
  recoveredByLlm: checkoutRecovered || undefined
5861
6227
  });
5862
- reportEnd(8, "go-checkout", step8Status, Date.now() - t8);
6228
+ reportEnd(9, "go-checkout", step9Status, Date.now() - t9);
5863
6229
  return { pages, steps };
5864
6230
  } finally {
5865
6231
  await page.close();
@@ -5879,15 +6245,10 @@ function makeSkipStep(step, name, ctx, note) {
5879
6245
  }
5880
6246
  async function findCategoryUrl(page, ctx) {
5881
6247
  const selectors = selFor(ctx, "categoryLink");
5882
- dlog2(ctx, ` findCategoryUrl: collectCandidateLinks(${selectors.length} selectors) start`);
5883
- const t0 = Date.now();
5884
6248
  const candidates = await collectCandidateLinks(page, selectors, 12);
5885
- dlog2(ctx, ` findCategoryUrl: collectCandidateLinks done in ${Date.now() - t0}ms → ${candidates.length} candidates`);
5886
6249
  if (candidates.length === 0)
5887
6250
  return null;
5888
- dlog2(ctx, " findCategoryUrl: pickCategoryLink LLM call start");
5889
6251
  const picked = await pickCategoryLink(candidates.map((c) => ({ text: c.text, href: c.href })));
5890
- dlog2(ctx, ` findCategoryUrl: pickCategoryLink LLM done → ${picked?.href ?? "null"}`);
5891
6252
  if (!picked)
5892
6253
  return null;
5893
6254
  const original = candidates.find((c) => c.href === picked.href);
@@ -5936,34 +6297,20 @@ async function firstVisibleLocator(page, selectors) {
5936
6297
  }
5937
6298
  return null;
5938
6299
  }
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
6300
  async function collectCandidateLinks(page, selectors, limit = 12) {
5947
6301
  const out = [];
5948
6302
  const seenHrefs = new Set;
5949
- const deadline = Date.now() + COLLECT_CANDIDATES_BUDGET_MS;
5950
- const expired = () => Date.now() >= deadline;
5951
6303
  for (const sel of selectors) {
5952
6304
  if (out.length >= limit)
5953
6305
  break;
5954
- if (expired())
5955
- break;
5956
6306
  try {
5957
6307
  const elements = page.locator(sel);
5958
- const count = await withCap(elements.count(), 1000, 0);
6308
+ const count = await elements.count();
5959
6309
  for (let i = 0;i < count && out.length < limit; i++) {
5960
- if (expired())
5961
- break;
5962
6310
  const el = elements.nth(i);
5963
- const visible = await withCap(el.isVisible({ timeout: 250 }).catch(() => false), 400, false);
5964
- if (!visible)
6311
+ if (!await el.isVisible({ timeout: 250 }).catch(() => false))
5965
6312
  continue;
5966
- const href = await withCap(el.getAttribute("href").catch(() => null), 400, null);
6313
+ const href = await el.getAttribute("href").catch(() => null);
5967
6314
  if (!href)
5968
6315
  continue;
5969
6316
  let abs = href;
@@ -5975,7 +6322,7 @@ async function collectCandidateLinks(page, selectors, limit = 12) {
5975
6322
  if (seenHrefs.has(abs))
5976
6323
  continue;
5977
6324
  seenHrefs.add(abs);
5978
- const text = (await withCap(el.innerText().catch(() => ""), 400, "")).slice(0, 60).trim();
6325
+ const text = (await el.innerText().catch(() => "")).slice(0, 60).trim();
5979
6326
  out.push({ text, href: abs, selector: sel });
5980
6327
  }
5981
6328
  } catch {}
@@ -5994,34 +6341,388 @@ async function fillCep(page, selector, cep) {
5994
6341
  return false;
5995
6342
  }
5996
6343
  }
5997
- var GENERIC_TITLE_PATTERNS = [
5998
- /^p[áa]gina de produto/i,
5999
- /^product page/i,
6000
- /^carregando/i,
6001
- /^loading/i,
6002
- /^undefined/i,
6003
- /^home$/i
6004
- ];
6005
- function looksGeneric(s) {
6006
- const t = s.trim();
6007
- return GENERIC_TITLE_PATTERNS.some((re) => re.test(t));
6344
+ async function selectVariant(page, ctx) {
6345
+ const actions = [];
6346
+ let primarySelectorKey;
6347
+ let primarySelector;
6348
+ const trackPrimary = (key, sel) => {
6349
+ if (!primarySelectorKey) {
6350
+ primarySelectorKey = key;
6351
+ primarySelector = sel;
6352
+ }
6353
+ };
6354
+ try {
6355
+ const rowSelectors = selFor(ctx, "variantRow");
6356
+ const incrementSelectors = selFor(ctx, "quantityIncrement");
6357
+ rowLoop:
6358
+ for (const rowSel of rowSelectors) {
6359
+ const rows = page.locator(rowSel);
6360
+ const count = await rows.count().catch(() => 0);
6361
+ for (let i = 0;i < Math.min(count, 5); i++) {
6362
+ const row = rows.nth(i);
6363
+ if (!await row.isVisible({ timeout: 500 }).catch(() => false))
6364
+ continue;
6365
+ const rowText = (await row.innerText().catch(() => "")).replace(/\s+/g, " ").trim();
6366
+ for (const incSel of incrementSelectors) {
6367
+ const plus = row.locator(incSel).first();
6368
+ if (!await plus.isVisible({ timeout: 500 }).catch(() => false))
6369
+ continue;
6370
+ if (await plus.isDisabled().catch(() => false))
6371
+ continue;
6372
+ await plus.click({ timeout: 2000 }).catch(() => {
6373
+ return;
6374
+ });
6375
+ await page.waitForTimeout(400);
6376
+ actions.push(`Incrementou quantidade da variante \`${rowSel}\`[${i}]${rowText ? ` (${rowText.slice(0, 40)})` : ""} via \`${incSel}\``);
6377
+ trackPrimary("variantRow", rowSel);
6378
+ break rowLoop;
6379
+ }
6380
+ }
6381
+ }
6382
+ } catch {}
6383
+ if (actions.length === 0) {
6384
+ const sizeHit = await firstVisibleLocator(page, selFor(ctx, "sizeSwatch"));
6385
+ if (sizeHit && !await sizeHit.locator.isDisabled().catch(() => false)) {
6386
+ const sizeText = (await sizeHit.locator.innerText().catch(() => "")).slice(0, 20).trim();
6387
+ await sizeHit.locator.click({ timeout: 2000 }).catch(() => {
6388
+ return;
6389
+ });
6390
+ await page.waitForTimeout(400);
6391
+ actions.push(`Selecionou tamanho${sizeText ? ` '${sizeText}'` : ""} (\`${sizeHit.selector}\`)`);
6392
+ trackPrimary("sizeSwatch", sizeHit.selector);
6393
+ }
6394
+ }
6395
+ const colorHit = await firstVisibleLocator(page, selFor(ctx, "colorSwatch"));
6396
+ if (colorHit && !await colorHit.locator.isDisabled().catch(() => false)) {
6397
+ const colorText = (await colorHit.locator.innerText().catch(() => "")).slice(0, 20).trim();
6398
+ const colorLabel = colorText || await colorHit.locator.getAttribute("aria-label").catch(() => null) || "";
6399
+ await colorHit.locator.click({ timeout: 2000 }).catch(() => {
6400
+ return;
6401
+ });
6402
+ await page.waitForTimeout(400);
6403
+ actions.push(`Selecionou cor${colorLabel ? ` '${colorLabel}'` : ""} (\`${colorHit.selector}\`)`);
6404
+ trackPrimary("colorSwatch", colorHit.selector);
6405
+ }
6406
+ if (actions.length === 0) {
6407
+ await scrollPageInChunks(page);
6408
+ const result = await findAndIncrementZeroQtyInput(page, ctx);
6409
+ if (result) {
6410
+ actions.push(result.description);
6411
+ trackPrimary(result.selectorKey, result.usedSelector);
6412
+ }
6413
+ }
6414
+ let variantRequired = false;
6415
+ try {
6416
+ const bodyText = await page.locator("body").innerText({ timeout: 1000 }).catch(() => "");
6417
+ variantRequired = VARIANT_REQUIRED_TEXT_PATTERNS.some((re) => re.test(bodyText));
6418
+ } catch {}
6419
+ return { actions, primarySelectorKey, primarySelector, variantRequired };
6008
6420
  }
6009
- async function extractProductTitle(page) {
6010
- await page.waitForSelector("h1", { timeout: 2500, state: "attached" }).catch(() => {
6421
+ async function scrollPageInChunks(page) {
6422
+ await page.evaluate(async () => {
6423
+ const height = document.body.scrollHeight;
6424
+ const stops = [0.2, 0.4, 0.6, 0.8, 1, 0.4];
6425
+ for (const frac of stops) {
6426
+ window.scrollTo({ top: height * frac, behavior: "instant" });
6427
+ await new Promise((r) => setTimeout(r, 300));
6428
+ }
6429
+ window.scrollTo({ top: 0, behavior: "instant" });
6430
+ }).catch(() => {
6011
6431
  return;
6012
6432
  });
6013
- const visibleSelectors = [
6014
- "main h1",
6015
- "h1[class*='product' i]",
6016
- "h1.product-title",
6017
- "[itemprop='name'][data-product-name]",
6018
- "[data-product-name]",
6019
- "[data-fs-product-title]",
6020
- "[itemprop='name']",
6021
- ".vtex-store-components-3-x-productNameContainer",
6022
- "h1"
6433
+ await page.waitForTimeout(1200);
6434
+ }
6435
+ async function findAndIncrementZeroQtyInput(page, ctx) {
6436
+ const qtySelectors = selFor(ctx, "quantityInput");
6437
+ const scopeSelectors = [
6438
+ "[data-manifest-key*='ProductDetails' i]",
6439
+ "[data-manifest-key*='Product/' i]",
6440
+ "main"
6023
6441
  ];
6024
- for (const sel of visibleSelectors) {
6442
+ const scopeRoots = [];
6443
+ for (const sel of scopeSelectors) {
6444
+ const cand = page.locator(sel).first();
6445
+ if (await cand.count().then((n) => n > 0).catch(() => false)) {
6446
+ scopeRoots.push({ root: cand, tag: "main" });
6447
+ break;
6448
+ }
6449
+ }
6450
+ scopeRoots.push({ root: null, tag: "page" });
6451
+ for (const { root, tag } of scopeRoots) {
6452
+ for (const sel of qtySelectors) {
6453
+ const all = root ? root.locator(sel) : page.locator(sel);
6454
+ const count = await all.count().catch(() => 0);
6455
+ for (let i = 0;i < Math.min(count, 20); i++) {
6456
+ const el = all.nth(i);
6457
+ if (!await el.isVisible({ timeout: 300 }).catch(() => false))
6458
+ continue;
6459
+ const value = (await el.inputValue().catch(() => "")).trim();
6460
+ if (value !== "" && value !== "0")
6461
+ continue;
6462
+ const plus = await findPlusButtonNear(el);
6463
+ if (plus) {
6464
+ if (await plus.isDisabled().catch(() => false))
6465
+ continue;
6466
+ await plus.click({ timeout: 2000 }).catch(() => {
6467
+ return;
6468
+ });
6469
+ await page.waitForTimeout(500);
6470
+ return {
6471
+ description: `Incrementou variante via botão '+' próximo ao input \`${sel}\` (scope=${tag}, índice ${i})`,
6472
+ selectorKey: "quantityInput",
6473
+ usedSelector: sel
6474
+ };
6475
+ }
6476
+ await el.fill("1", { timeout: 1500 }).catch(() => {
6477
+ return;
6478
+ });
6479
+ await el.dispatchEvent("input").catch(() => {
6480
+ return;
6481
+ });
6482
+ await el.dispatchEvent("change").catch(() => {
6483
+ return;
6484
+ });
6485
+ await page.waitForTimeout(400);
6486
+ return {
6487
+ description: `Ajustou quantidade do input \`${sel}\` para 1 (scope=${tag}, índice ${i})`,
6488
+ selectorKey: "quantityInput",
6489
+ usedSelector: sel
6490
+ };
6491
+ }
6492
+ }
6493
+ }
6494
+ return null;
6495
+ }
6496
+ async function findPlusButtonNear(input) {
6497
+ 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(.)='+']";
6498
+ const inAncestor = input.locator(ancestorXPath).first();
6499
+ if (await inAncestor.isVisible({ timeout: 600 }).catch(() => false)) {
6500
+ return inAncestor;
6501
+ }
6502
+ for (let depth = 1;depth <= 6; depth++) {
6503
+ const climb = `xpath=ancestor::*[${depth}]//button[normalize-space(.)='+']`;
6504
+ const cand = input.locator(climb).first();
6505
+ if (await cand.isVisible({ timeout: 300 }).catch(() => false)) {
6506
+ return cand;
6507
+ }
6508
+ }
6509
+ const sibling = input.locator("xpath=following-sibling::button[1]").first();
6510
+ if (await sibling.isVisible({ timeout: 300 }).catch(() => false)) {
6511
+ const text = (await sibling.innerText().catch(() => "")).trim();
6512
+ if (text === "+")
6513
+ return sibling;
6514
+ }
6515
+ return null;
6516
+ }
6517
+ async function readCartCount(page, ctx) {
6518
+ const selectors = selFor(ctx, "minicartCount");
6519
+ for (const sel of selectors) {
6520
+ try {
6521
+ const el = page.locator(sel).first();
6522
+ if (!await el.isVisible({ timeout: 500 }).catch(() => false))
6523
+ continue;
6524
+ const raw = (await el.innerText().catch(() => "")).trim();
6525
+ const match = raw.match(/\d+/);
6526
+ if (match)
6527
+ return Number.parseInt(match[0], 10);
6528
+ } catch {}
6529
+ }
6530
+ return 0;
6531
+ }
6532
+ async function validateAddToCart(page, ctx, cartCountBefore, beforeUrl) {
6533
+ const deadline = Date.now() + 3000;
6534
+ let lastErrorText;
6535
+ while (Date.now() < deadline) {
6536
+ const currentUrl = page.url();
6537
+ if (currentUrl !== beforeUrl && /\/(cart|carrinho|checkout)(\/|$|\?)/i.test(currentUrl)) {
6538
+ return {
6539
+ status: "ok",
6540
+ signal: "url-changed",
6541
+ note: `URL mudou para \`${currentUrl}\` (carrinho/checkout)`
6542
+ };
6543
+ }
6544
+ const cartCountNow = await readCartCount(page, ctx);
6545
+ if (cartCountNow > cartCountBefore) {
6546
+ return {
6547
+ status: "ok",
6548
+ signal: "count-increased",
6549
+ note: `Contador do minicart foi de ${cartCountBefore} para ${cartCountNow}`
6550
+ };
6551
+ }
6552
+ const drawerSelectors = [
6553
+ "[role='dialog']",
6554
+ "[data-minicart][aria-expanded='true']",
6555
+ "[data-cart-drawer].open",
6556
+ "[data-minicart-drawer]:not([hidden])",
6557
+ ".minicart--open",
6558
+ ".cart-drawer--open"
6559
+ ];
6560
+ for (const sel of drawerSelectors) {
6561
+ const el = page.locator(sel).first();
6562
+ if (await el.isVisible({ timeout: 200 }).catch(() => false)) {
6563
+ return {
6564
+ status: "ok",
6565
+ signal: "drawer-open",
6566
+ note: `Drawer/modal do carrinho abriu (\`${sel}\`)`
6567
+ };
6568
+ }
6569
+ }
6570
+ try {
6571
+ const bodyText = await page.locator("body").innerText({ timeout: 500 }).catch(() => "");
6572
+ for (const re of ADD_TO_CART_SUCCESS_PATTERNS) {
6573
+ const match = bodyText.match(re);
6574
+ if (match) {
6575
+ return {
6576
+ status: "ok",
6577
+ signal: "success-toast",
6578
+ note: `Toast de sucesso visível: '${match[0]}'`
6579
+ };
6580
+ }
6581
+ }
6582
+ for (const re of ADD_TO_CART_ERROR_PATTERNS) {
6583
+ const match = bodyText.match(re);
6584
+ if (match) {
6585
+ lastErrorText = match[0];
6586
+ break;
6587
+ }
6588
+ }
6589
+ } catch {}
6590
+ await page.waitForTimeout(250);
6591
+ }
6592
+ if (lastErrorText) {
6593
+ return {
6594
+ status: "failed",
6595
+ signal: "error-text",
6596
+ note: `add-to-cart silenciosamente falhou: '${lastErrorText}' visível na página`,
6597
+ errorText: lastErrorText
6598
+ };
6599
+ }
6600
+ return {
6601
+ status: "failed",
6602
+ signal: "no-signal",
6603
+ note: "add-to-cart sem confirmação visível (minicart não atualizou, sem drawer, sem mudança de URL)"
6604
+ };
6605
+ }
6606
+ async function attemptRecovery(page, _ctx, stepName, intendedAction, alreadyTried) {
6607
+ let html = "";
6608
+ try {
6609
+ html = await page.content();
6610
+ } catch {
6611
+ return null;
6612
+ }
6613
+ const suggestion = await suggestRecovery({ stepName, intendedAction, html, alreadyTried });
6614
+ if (!suggestion)
6615
+ return null;
6616
+ try {
6617
+ const el = page.locator(suggestion.selector).first();
6618
+ if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
6619
+ return { locator: el, selector: suggestion.selector };
6620
+ }
6621
+ } catch {}
6622
+ return null;
6623
+ }
6624
+ async function attemptStepAction(args) {
6625
+ const { page, ctx, stepName, intendedAction, selectorKey, action, value, recoveryBudget } = args;
6626
+ const selectors = selFor(ctx, selectorKey);
6627
+ for (const sel of selectors) {
6628
+ try {
6629
+ const el = page.locator(sel).first();
6630
+ if (!await el.isVisible({ timeout: 800 }).catch(() => false))
6631
+ continue;
6632
+ const ok = await performAction(el, action, value);
6633
+ if (ok) {
6634
+ return { performed: true, selector: sel, action, recoveredByLlm: false };
6635
+ }
6636
+ } catch {}
6637
+ }
6638
+ if (recoveryBudget.remaining <= 0) {
6639
+ return { performed: false, selector: "", action, recoveredByLlm: false };
6640
+ }
6641
+ let html = "";
6642
+ try {
6643
+ html = await page.content();
6644
+ } catch {
6645
+ return { performed: false, selector: "", action, recoveredByLlm: false };
6646
+ }
6647
+ const suggestion = await suggestRecovery({
6648
+ stepName,
6649
+ intendedAction,
6650
+ html,
6651
+ alreadyTried: selectors
6652
+ });
6653
+ if (!suggestion) {
6654
+ return { performed: false, selector: "", action, recoveredByLlm: false };
6655
+ }
6656
+ recoveryBudget.remaining--;
6657
+ const llmAction = suggestion.action ?? action;
6658
+ const llmValue = suggestion.value ?? value;
6659
+ try {
6660
+ const el = page.locator(suggestion.selector).first();
6661
+ if (!await el.isVisible({ timeout: 2000 }).catch(() => false)) {
6662
+ return { performed: false, selector: "", action: llmAction, recoveredByLlm: false };
6663
+ }
6664
+ const ok = await performAction(el, llmAction, llmValue);
6665
+ if (ok) {
6666
+ return {
6667
+ performed: true,
6668
+ selector: suggestion.selector,
6669
+ action: llmAction,
6670
+ recoveredByLlm: true
6671
+ };
6672
+ }
6673
+ } catch {}
6674
+ return { performed: false, selector: "", action: llmAction, recoveredByLlm: false };
6675
+ }
6676
+ async function performAction(el, action, value) {
6677
+ try {
6678
+ if (action === "click") {
6679
+ await el.click({ timeout: 3000 });
6680
+ return true;
6681
+ }
6682
+ if (action === "fill") {
6683
+ await el.fill(value ?? "", { timeout: 3000 });
6684
+ await el.press("Enter", { timeout: 1500 }).catch(() => {
6685
+ return;
6686
+ });
6687
+ return true;
6688
+ }
6689
+ if (action === "press") {
6690
+ await el.press(value ?? "Enter", { timeout: 1500 });
6691
+ return true;
6692
+ }
6693
+ } catch {
6694
+ return false;
6695
+ }
6696
+ return false;
6697
+ }
6698
+ var GENERIC_TITLE_PATTERNS = [
6699
+ /^p[áa]gina de produto/i,
6700
+ /^product page/i,
6701
+ /^carregando/i,
6702
+ /^loading/i,
6703
+ /^undefined/i,
6704
+ /^home$/i
6705
+ ];
6706
+ function looksGeneric(s) {
6707
+ const t = s.trim();
6708
+ return GENERIC_TITLE_PATTERNS.some((re) => re.test(t));
6709
+ }
6710
+ async function extractProductTitle(page) {
6711
+ await page.waitForSelector("h1", { timeout: 2500, state: "attached" }).catch(() => {
6712
+ return;
6713
+ });
6714
+ const visibleSelectors = [
6715
+ "main h1",
6716
+ "h1[class*='product' i]",
6717
+ "h1.product-title",
6718
+ "[itemprop='name'][data-product-name]",
6719
+ "[data-product-name]",
6720
+ "[data-fs-product-title]",
6721
+ "[itemprop='name']",
6722
+ ".vtex-store-components-3-x-productNameContainer",
6723
+ "h1"
6724
+ ];
6725
+ for (const sel of visibleSelectors) {
6025
6726
  try {
6026
6727
  const el = page.locator(sel).first();
6027
6728
  const visible = await withCap(el.isVisible({ timeout: 250 }).catch(() => false), 400, false);
@@ -6077,6 +6778,80 @@ async function isCartUiVisible(page) {
6077
6778
  "[class*='drawer-cart']:visible"
6078
6779
  ]);
6079
6780
  }
6781
+ async function detectCartRevealMode(page, trigger, drawerAlreadyOpen, viewport) {
6782
+ if (drawerAlreadyOpen)
6783
+ return "inline-notification";
6784
+ try {
6785
+ const href = await trigger.getAttribute("href").catch(() => null) ?? "";
6786
+ const lower = href.toLowerCase();
6787
+ if (/\/checkout(\b|\/|\?|#)/i.test(lower))
6788
+ return "click-navigate-checkout";
6789
+ if (/\/cart(\b|\/|\?|#)/i.test(lower) || /\/carrinho(\b|\/|\?|#)/i.test(lower)) {
6790
+ return "click-navigate-cart";
6791
+ }
6792
+ } catch {}
6793
+ let hasClickAttr = false;
6794
+ try {
6795
+ const onclick = await trigger.getAttribute("onclick").catch(() => null);
6796
+ if (onclick && onclick.trim().length > 0)
6797
+ hasClickAttr = true;
6798
+ } catch {}
6799
+ if (viewport === "desktop") {
6800
+ try {
6801
+ const observedHoverDrawer = await page.evaluate(async () => {
6802
+ return await new Promise((resolve) => {
6803
+ const selectorMatch = (el) => {
6804
+ if (!(el instanceof HTMLElement))
6805
+ return false;
6806
+ if (el.getAttribute("role") === "dialog")
6807
+ return true;
6808
+ const cls = `${el.className || ""}`;
6809
+ return /minicart|drawer|cart-popup|cart-modal/i.test(cls);
6810
+ };
6811
+ const observer = new MutationObserver((muts) => {
6812
+ for (const m of muts) {
6813
+ for (const n of Array.from(m.addedNodes)) {
6814
+ if (n instanceof Element && selectorMatch(n)) {
6815
+ observer.disconnect();
6816
+ resolve(true);
6817
+ return;
6818
+ }
6819
+ }
6820
+ if (m.type === "attributes" && m.target instanceof Element && selectorMatch(m.target)) {
6821
+ observer.disconnect();
6822
+ resolve(true);
6823
+ return;
6824
+ }
6825
+ }
6826
+ });
6827
+ observer.observe(document.body, {
6828
+ childList: true,
6829
+ subtree: true,
6830
+ attributes: true,
6831
+ attributeFilter: ["class", "style", "aria-expanded", "open"]
6832
+ });
6833
+ setTimeout(() => {
6834
+ observer.disconnect();
6835
+ resolve(false);
6836
+ }, 600);
6837
+ });
6838
+ });
6839
+ const hoverPromise = trigger.hover({ timeout: 1500 }).catch(() => {
6840
+ return;
6841
+ });
6842
+ await hoverPromise;
6843
+ const result = await observedHoverDrawer;
6844
+ await page.mouse.move(0, 0).catch(() => {
6845
+ return;
6846
+ });
6847
+ if (result)
6848
+ return "hover-drawer";
6849
+ } catch {}
6850
+ }
6851
+ if (hasClickAttr)
6852
+ return "click-drawer";
6853
+ return "unknown";
6854
+ }
6080
6855
  async function isCartRevealed(page, expectedProductTitle) {
6081
6856
  if (expectedProductTitle) {
6082
6857
  const v = await validateCartContainsTitleQuick(page, expectedProductTitle);
@@ -6246,7 +7021,7 @@ async function openMinicart(page, trigger, ctx, expectedProductTitle) {
6246
7021
  }
6247
7022
  })();
6248
7023
  if (targetUrl) {
6249
- dlog2(ctx, ` openMinicart: all interactive strategies failed but trigger has cart href, navigating directly to ${targetUrl}`);
7024
+ dlog2(ctx, ` openMinicart: all interactive strategies failed; navigating directly to ${targetUrl}`);
6250
7025
  await page.goto(targetUrl, { waitUntil: "load", timeout: 15000 }).catch(() => {
6251
7026
  return;
6252
7027
  });
@@ -6345,7 +7120,7 @@ async function validateCartContainsTitle(page, expectedTitle, ctx) {
6345
7120
  await page.waitForTimeout(2000);
6346
7121
  observed = await sweepTitles();
6347
7122
  }
6348
- dlog2(ctx, ` validateCartContainsTitle: observed ${observed.length} titles ${observed.length ? `[${observed.slice(0, 3).map((t) => t.slice(0, 30)).join(" | ")}${observed.length > 3 ? " | …" : ""}]` : ""}`);
7123
+ dlog2(ctx, ` validateCartContainsTitle: observed ${observed.length} titles`);
6349
7124
  if (observed.length === 0) {
6350
7125
  return { found: false, observedTitles: [], method: "none" };
6351
7126
  }
@@ -6374,24 +7149,6 @@ async function detectEmptyCartBanner(page) {
6374
7149
  }
6375
7150
  return null;
6376
7151
  }
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
7152
 
6396
7153
  // src/ignore/parser.ts
6397
7154
  import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
@@ -6501,6 +7258,51 @@ function detectFromHtml(html) {
6501
7258
  return "custom";
6502
7259
  }
6503
7260
 
7261
+ // src/learned/promote.ts
7262
+ function promoteStepsFromFlow(learned, platform, host, flow) {
7263
+ let promoted = 0;
7264
+ let deprecated = 0;
7265
+ let recorded = 0;
7266
+ for (const step of flow.steps ?? []) {
7267
+ if (!step.selectorKey || !step.usedSelector)
7268
+ continue;
7269
+ if (!isReusableSelector(step.usedSelector))
7270
+ continue;
7271
+ const parsed = SelectorKey.safeParse(step.selectorKey);
7272
+ if (!parsed.success)
7273
+ continue;
7274
+ const key = parsed.data;
7275
+ if (step.recoveredByLlm) {
7276
+ promoteFromLlm(learned, platform, key, step.usedSelector, host);
7277
+ promoted++;
7278
+ } else if (step.status === "ok") {
7279
+ recordSuccess(learned, platform, key, step.usedSelector, host);
7280
+ recorded++;
7281
+ } else if (step.status === "failed") {
7282
+ const wasDeprecated = isAlreadyDeprecated(learned, platform, key, step.usedSelector);
7283
+ const after = recordFailure(learned, platform, key, step.usedSelector, host);
7284
+ if (!wasDeprecated && after?.deprecated)
7285
+ deprecated++;
7286
+ }
7287
+ }
7288
+ return { promoted, deprecated, recorded };
7289
+ }
7290
+ function isReusableSelector(s) {
7291
+ if (s.includes("→"))
7292
+ return false;
7293
+ if (s.includes(" ~ "))
7294
+ return false;
7295
+ return s.trim().length > 0;
7296
+ }
7297
+ function isAlreadyDeprecated(lib, platform, key, selector) {
7298
+ const platformEntries = lib.platforms[platform];
7299
+ if (!platformEntries)
7300
+ return false;
7301
+ const entries = platformEntries[key] ?? [];
7302
+ const entry = entries.find((e) => e.selector === selector);
7303
+ return !!entry?.deprecated;
7304
+ }
7305
+
6504
7306
  // src/llm/discover-selectors.ts
6505
7307
  import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "node:fs";
6506
7308
  import { join as join6 } from "node:path";
@@ -6526,26 +7328,26 @@ var DISCOVER_SELECTORS_TOOL = {
6526
7328
  },
6527
7329
  minicart_trigger: {
6528
7330
  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)."
7331
+ 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
7332
  },
6531
7333
  cep_input_pdp: {
6532
7334
  type: "string",
6533
- description: "CSS selector for the CEP / zipcode <input> on a PDP. Empty string if the PDP has no shipping calculator."
7335
+ 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
7336
  },
6535
7337
  cep_input_cart: {
6536
7338
  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."
7339
+ 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
7340
  },
6539
7341
  checkout_button: {
6540
7342
  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')").`
7343
+ 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
7344
  },
6543
7345
  reasoning: {
6544
7346
  type: "string",
6545
7347
  description: "1-2 sentence rationale describing what was inferred from the markup."
6546
7348
  }
6547
7349
  },
6548
- required: ["category_link", "product_card", "buy_button", "minicart_trigger", "checkout_button"]
7350
+ required: ["category_link", "product_card", "buy_button", "minicart_trigger"]
6549
7351
  }
6550
7352
  };
6551
7353
  var SYSTEM_PROMPT = `
@@ -6582,7 +7384,17 @@ REGRAS:
6582
7384
  4. Para "cep_input_pdp" e "cep_input_cart": retorne string vazia se não existir esse recurso na plataforma.
6583
7385
  Não invente.
6584
7386
 
6585
- 5. Sempre teste mentalmente: "este seletor pegaria o elemento certo na home E também em PDPs/cart?"
7387
+ 5. **NUNCA confunda \`minicart_trigger\` (ícone do carrinho no HEADER) com \`checkout_button\`
7388
+ (botão "Finalizar" DENTRO do drawer ou na página de cart).** Se você só vê o ícone do
7389
+ carrinho na home e não consegue ver o botão de checkout (caso comum — ele só renderiza
7390
+ depois de add-to-cart), retorne STRING VAZIA pra \`checkout_button\`. NUNCA retorne o
7391
+ mesmo seletor para os dois — isso quebra o fluxo de teste.
7392
+
7393
+ 6. Para CEP: \`cep_input_pdp\` e \`cep_input_cart\` são pra ENDEREÇO (CEP / Postal Code).
7394
+ NÃO confunda com input de CUPOM ('Digite o cupom', 'Insira código', 'Promo code') —
7395
+ cupom é desconto, não frete.
7396
+
7397
+ 7. Sempre teste mentalmente: "este seletor pegaria o elemento certo na home E também em PDPs/cart?"
6586
7398
 
6587
7399
  Responda SEMPRE via tool_use report_selectors. Não escreva texto livre fora da tool call.
6588
7400
  `.trim();
@@ -6656,12 +7468,24 @@ ${compacted}
6656
7468
  cepInputCart: emptyToUndef(input.cep_input_cart),
6657
7469
  checkoutButton: emptyToUndef(input.checkout_button)
6658
7470
  };
7471
+ if (selectors.checkoutButton && selectors.minicartTrigger && selectorsLikelyConflict(selectors.checkoutButton, selectors.minicartTrigger)) {
7472
+ console.warn(`[discover-selectors] checkout_button == minicart_trigger (\`${selectors.checkoutButton}\`); dropping to avoid step-9 misclick`);
7473
+ selectors.checkoutButton = undefined;
7474
+ }
6659
7475
  if (!existsSync6(cacheDir))
6660
7476
  mkdirSync4(cacheDir, { recursive: true });
6661
7477
  writeFileSync5(cachePath, `${JSON.stringify(selectors, null, 2)}
6662
7478
  `, "utf8");
6663
7479
  return selectors;
6664
7480
  }
7481
+ function selectorsLikelyConflict(a, b) {
7482
+ if (a === b)
7483
+ return true;
7484
+ const norm = (s) => s.replace(/\s+/g, "").toLowerCase();
7485
+ const na = norm(a);
7486
+ const nb = norm(b);
7487
+ return na.includes(nb) || nb.includes(na);
7488
+ }
6665
7489
  function emptyToUndef(v) {
6666
7490
  if (v == null)
6667
7491
  return;
@@ -6681,11 +7505,12 @@ var STEP_LABELS = {
6681
7505
  "visit-home": "1. visit-home",
6682
7506
  "navigate-plp": "2. navigate-plp",
6683
7507
  "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"
7508
+ "select-variant": "4. select-variant",
7509
+ "shipping-calc-pdp": "5. shipping-calc-pdp",
7510
+ "add-to-cart": "6. add-to-cart",
7511
+ "open-minicart": "7. open-minicart",
7512
+ "shipping-calc-cart": "8. shipping-calc-cart",
7513
+ "go-checkout": "9. go-checkout"
6689
7514
  };
6690
7515
  var CRITICAL_STEPS = new Set([
6691
7516
  "visit-home",
@@ -6720,7 +7545,7 @@ async function journeyCommand(opts) {
6720
7545
  let platform = "custom";
6721
7546
  try {
6722
7547
  const homeRes = await fetch(opts.prod, {
6723
- headers: { "User-Agent": "Mozilla/5.0 parity-cli" }
7548
+ headers: { "User-Agent": userAgentFor(viewports[0] ?? "mobile") }
6724
7549
  });
6725
7550
  if (homeRes.ok) {
6726
7551
  const html = await homeRes.text();
@@ -6789,7 +7614,8 @@ async function journeyCommand(opts) {
6789
7614
  learned,
6790
7615
  platform,
6791
7616
  recoveryBudget: 3,
6792
- onStep: onStepFor(viewport, side)
7617
+ onStep: onStepFor(viewport, side),
7618
+ acceptProdQuirks: opts.acceptProdQuirks
6793
7619
  });
6794
7620
  return cap;
6795
7621
  } finally {
@@ -6814,6 +7640,39 @@ async function journeyCommand(opts) {
6814
7640
  }
6815
7641
  if (!opts.json)
6816
7642
  console.log("");
7643
+ const learnedBefore = JSON.stringify(learned);
7644
+ const prodHost = safeHost2(opts.prod);
7645
+ let totalPromoted = 0;
7646
+ let totalDeprecated = 0;
7647
+ let totalRecorded = 0;
7648
+ for (const cap of flowCaptures) {
7649
+ if (cap.side !== "prod")
7650
+ continue;
7651
+ const result = promoteStepsFromFlow(learned, platform, prodHost, cap);
7652
+ totalPromoted += result.promoted;
7653
+ totalDeprecated += result.deprecated;
7654
+ totalRecorded += result.recorded;
7655
+ }
7656
+ if (JSON.stringify(learned) !== learnedBefore) {
7657
+ try {
7658
+ saveLearned(learned);
7659
+ if (!opts.json) {
7660
+ const bits = [];
7661
+ if (totalPromoted > 0)
7662
+ bits.push(`${totalPromoted} promovido(s) via LLM`);
7663
+ if (totalRecorded > 0)
7664
+ bits.push(`${totalRecorded} reforçado(s)`);
7665
+ if (totalDeprecated > 0)
7666
+ bits.push(`${totalDeprecated} depreciado(s)`);
7667
+ if (bits.length > 0)
7668
+ console.log(chalk6.dim(` learned-selectors atualizado: ${bits.join(", ")}`));
7669
+ }
7670
+ } catch (err) {
7671
+ if (!opts.json) {
7672
+ console.warn(chalk6.yellow(` warn: failed to persist learned-selectors: ${err.message}`));
7673
+ }
7674
+ }
7675
+ }
6817
7676
  const rows = buildRows(flowCaptures, viewports);
6818
7677
  const failures = collectFailures(rows);
6819
7678
  writeRunReportJson(paths.runDir, buildJourneyRun(opts, runId, flowCaptures, failures));
@@ -7171,6 +8030,13 @@ function renderSummaryBlock(rows, failures) {
7171
8030
  function escapeHtml(s) {
7172
8031
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
7173
8032
  }
8033
+ function safeHost2(url) {
8034
+ try {
8035
+ return new URL(url).hostname;
8036
+ } catch {
8037
+ return url;
8038
+ }
8039
+ }
7174
8040
 
7175
8041
  // src/commands/learned.ts
7176
8042
  import chalk7 from "chalk";
@@ -7900,6 +8766,17 @@ function normalizeUrl(rawUrl) {
7900
8766
  }
7901
8767
 
7902
8768
  // src/diff/dom.ts
8769
+ var BANNER_WIDTH_THRESHOLD = 600;
8770
+ var BANNER_SECTION_RE = /(carousel|slider|banner|hero)/i;
8771
+ function parseDim(raw) {
8772
+ if (!raw)
8773
+ return null;
8774
+ const trimmed = raw.replace(/px$/i, "").trim();
8775
+ if (!trimmed)
8776
+ return null;
8777
+ const n = Number(trimmed);
8778
+ return Number.isFinite(n) && n > 0 ? n : null;
8779
+ }
7903
8780
  function snapshotDom(html) {
7904
8781
  const $ = cheerio5.load(html);
7905
8782
  const counts = {
@@ -7947,12 +8824,29 @@ function snapshotDom(html) {
7947
8824
  jsonLdTypes: jsonLdTypes.sort()
7948
8825
  };
7949
8826
  const imgs = $("img");
8827
+ const banners = [];
8828
+ imgs.each((_, el) => {
8829
+ const $el = $(el);
8830
+ const widthAttr = parseDim($el.attr("width"));
8831
+ const heightAttr = parseDim($el.attr("height"));
8832
+ const sectionAncestor = $el.parents("[data-section]").filter((_2, p) => BANNER_SECTION_RE.test($(p).attr("data-section") ?? "")).first();
8833
+ const sectionName = sectionAncestor.length > 0 ? sectionAncestor.attr("data-section") ?? null : null;
8834
+ const looksLikeBanner = sectionName !== null || widthAttr !== null && widthAttr >= BANNER_WIDTH_THRESHOLD;
8835
+ if (!looksLikeBanner)
8836
+ return;
8837
+ const src = $el.attr("src") ?? "";
8838
+ if (!src)
8839
+ return;
8840
+ const aspectRatio = widthAttr && heightAttr && heightAttr > 0 ? widthAttr / heightAttr : null;
8841
+ banners.push({ src, width: widthAttr, height: heightAttr, aspectRatio, sectionName });
8842
+ });
7950
8843
  const imageStats = {
7951
8844
  total: imgs.length,
7952
8845
  withSrcset: imgs.filter((_, el) => Boolean($(el).attr("srcset"))).length,
7953
8846
  withAlt: imgs.filter((_, el) => Boolean($(el).attr("alt"))).length,
7954
8847
  withoutAlt: imgs.filter((_, el) => !$(el).attr("alt")).length,
7955
- src: imgs.map((_, el) => $(el).attr("src") ?? "").get().filter(Boolean)
8848
+ src: imgs.map((_, el) => $(el).attr("src") ?? "").get().filter(Boolean),
8849
+ banners
7956
8850
  };
7957
8851
  const decoSectionsRendered = [];
7958
8852
  $("[data-section]").each((_, el) => {
@@ -8271,6 +9165,9 @@ function buildContextBlock(input) {
8271
9165
  if (input.sectionsOnlyInProd && input.sectionsOnlyInProd.length > 0) {
8272
9166
  lines.push("", "**Sections detectadas no DOM de prod mas AUSENTES em cand** (provavelmente faltando migrar):", ...input.sectionsOnlyInProd.map((s) => `- ${s}`), "", "Verifique visualmente se essas sections aparecem na 2ª imagem. Se ausentes, reporte como missing-component com severity high ou critical.");
8273
9167
  }
9168
+ if (input.bothHaveCarousel) {
9169
+ lines.push("", "**Ambos os lados expõem um carousel/slider no DOM.** Diferenças no conteúdo do hero (banner diferente, texto diferente, imagem diferente) provavelmente são apenas slides diferentes do mesmo carousel capturados em momentos distintos. NÃO reporte essas diferenças como critical/high. Se o carousel inteiro sumir, ou o layout do hero quebrar, isso sim reporte normalmente.");
9170
+ }
8274
9171
  if (input.prodSections && input.prodSections.length > 0) {
8275
9172
  lines.push("", `Total sections em prod: ${input.prodSections.length}; em cand: ${input.candSections?.length ?? 0}.`);
8276
9173
  }
@@ -8329,6 +9226,32 @@ function pathFromKey(key) {
8329
9226
  function severityForDiff(d) {
8330
9227
  return d.severity;
8331
9228
  }
9229
+ var CAROUSEL_SECTION_RE = /(carousel|slider)/i;
9230
+ function hasCarouselSection(sections) {
9231
+ return sections.some((s) => CAROUSEL_SECTION_RE.test(s));
9232
+ }
9233
+ function downgradeCarouselFramingDiffs(diffs, bothHaveCarousel) {
9234
+ if (!bothHaveCarousel)
9235
+ return diffs;
9236
+ const CAROUSEL_FRAME_TYPES = new Set([
9237
+ "different-component",
9238
+ "image-diff",
9239
+ "text-changed"
9240
+ ]);
9241
+ return diffs.map((d) => {
9242
+ if (d.region !== "hero")
9243
+ return d;
9244
+ if (!CAROUSEL_FRAME_TYPES.has(d.type))
9245
+ return d;
9246
+ if (d.severity === "low")
9247
+ return d;
9248
+ return {
9249
+ ...d,
9250
+ severity: "low",
9251
+ description: `${d.description} [downgraded: ambos os lados expõem um carousel/slider — provável diferença só de slide ativo no momento da captura]`
9252
+ };
9253
+ });
9254
+ }
8332
9255
  async function visualRegressionKeyframes(ctx) {
8333
9256
  const start = Date.now();
8334
9257
  const { pairs } = pairCaptures(ctx.prodPages, ctx.candPages);
@@ -8372,6 +9295,7 @@ async function visualRegressionKeyframes(ctx) {
8372
9295
  const prodSet = new Set(prodSections);
8373
9296
  const sectionsOnlyInProd = prodSections.filter((s) => !candSet.has(s));
8374
9297
  const sectionsOnlyInCand = candSections.filter((s) => !prodSet.has(s));
9298
+ const bothHaveCarousel = hasCarouselSection(prodSections) && hasCarouselSection(candSections);
8375
9299
  const trivial = pctDiff < PASS_PCT_THRESHOLD && sectionsOnlyInProd.length === 0;
8376
9300
  let differences = [];
8377
9301
  let llmCalled = false;
@@ -8387,13 +9311,17 @@ async function visualRegressionKeyframes(ctx) {
8387
9311
  viewport: pair.viewport,
8388
9312
  prodSections,
8389
9313
  candSections,
8390
- sectionsOnlyInProd
9314
+ sectionsOnlyInProd,
9315
+ bothHaveCarousel
8391
9316
  });
8392
9317
  differences = diffs ?? [];
8393
9318
  } catch (err) {
8394
9319
  llmError = err.message;
8395
9320
  }
8396
9321
  }
9322
+ if (differences.length > 0) {
9323
+ differences = downgradeCarouselFramingDiffs(differences, bothHaveCarousel);
9324
+ }
8397
9325
  let verdict;
8398
9326
  const hasCritical = differences.some((d) => d.severity === "critical" || d.severity === "high");
8399
9327
  const hasAnyDiff = differences.length > 0 || sectionsOnlyInProd.length > 0;
@@ -8489,6 +9417,7 @@ var STEP_LABELS2 = {
8489
9417
  "visit-home": "Visitar home",
8490
9418
  "navigate-plp": "Navegar para categoria (PLP)",
8491
9419
  "enter-pdp": "Entrar em PDP",
9420
+ "select-variant": "Selecionar variante (tamanho/cor/quantidade)",
8492
9421
  "shipping-calc-pdp": "Cálculo de frete na PDP",
8493
9422
  "add-to-cart": "Adicionar ao carrinho",
8494
9423
  "open-minicart": "Abrir minicart",
@@ -8817,6 +9746,179 @@ function countFailedImageRequests(entries) {
8817
9746
  return entries.filter((e) => e.resourceType === "image" && (e.status === 0 || e.status >= 400)).length;
8818
9747
  }
8819
9748
 
9749
+ // src/checks/banner-aspect-ratio.ts
9750
+ var ASPECT_RATIO_TOLERANCE = 0.15;
9751
+ function bannerAspectRatio(ctx) {
9752
+ const start = Date.now();
9753
+ const { pairs } = pairCaptures(ctx.prodPages, ctx.candPages);
9754
+ const issues = [];
9755
+ let pagesChecked = 0;
9756
+ let pagesWithIssues = 0;
9757
+ for (const pair of pairs) {
9758
+ if (!pair.prod.html || !pair.cand.html)
9759
+ continue;
9760
+ pagesChecked++;
9761
+ const prodBanners = snapshotDom(pair.prod.html).imageStats.banners;
9762
+ const candBanners = snapshotDom(pair.cand.html).imageStats.banners;
9763
+ if (prodBanners.length === 0 && candBanners.length === 0)
9764
+ continue;
9765
+ const pageIssuesBefore = issues.length;
9766
+ const len = Math.min(prodBanners.length, candBanners.length);
9767
+ const comparisons = [];
9768
+ for (let i = 0;i < len; i++) {
9769
+ const prod = prodBanners[i];
9770
+ const cand = candBanners[i];
9771
+ if (prod && cand)
9772
+ comparisons.push({ index: i, prod, cand });
9773
+ }
9774
+ for (const cmp of comparisons) {
9775
+ issues.push(...issuesForPair(pair.key, cmp));
9776
+ }
9777
+ if (prodBanners.length !== candBanners.length) {
9778
+ issues.push({
9779
+ id: `banner-aspect:count:${pair.key}`,
9780
+ severity: "medium",
9781
+ category: "visual",
9782
+ page: pair.key,
9783
+ check: "banner-aspect-ratio",
9784
+ summary: `Banner count divergente em ${pair.key}: prod=${prodBanners.length}, cand=${candBanners.length}`
9785
+ });
9786
+ }
9787
+ if (issues.length > pageIssuesBefore)
9788
+ pagesWithIssues++;
9789
+ }
9790
+ const status = issues.some((i) => i.severity === "high" || i.severity === "critical") ? "fail" : issues.length > 0 ? "warn" : "pass";
9791
+ return {
9792
+ name: "banner-aspect-ratio",
9793
+ status,
9794
+ severity: "medium",
9795
+ durationMs: Date.now() - start,
9796
+ summary: `${pagesChecked} página(s) analisada(s), ${pagesWithIssues} com diferença de aspect ratio em banner`,
9797
+ issues,
9798
+ data: { pagesChecked, pagesWithIssues }
9799
+ };
9800
+ }
9801
+ function issuesForPair(pageKey, cmp) {
9802
+ const { prod, cand, index } = cmp;
9803
+ const out = [];
9804
+ const label = describeBanner(prod) || describeBanner(cand) || `banner #${index + 1}`;
9805
+ const prodHasDims = prod.width !== null && prod.height !== null;
9806
+ const candHasDims = cand.width !== null && cand.height !== null;
9807
+ if (prodHasDims && !candHasDims) {
9808
+ out.push({
9809
+ id: `banner-aspect:missing-dims:${pageKey}:${index}`,
9810
+ severity: "medium",
9811
+ category: "performance",
9812
+ page: pageKey,
9813
+ check: "banner-aspect-ratio",
9814
+ summary: `Atributos width/height ausentes em cand para ${label} (prod=${prod.width}×${prod.height}). Aumenta CLS porque o navegador não consegue reservar o slot antes da imagem decodificar.`
9815
+ });
9816
+ }
9817
+ if (prod.aspectRatio !== null && cand.aspectRatio !== null) {
9818
+ const ratioDelta = Math.abs(prod.aspectRatio - cand.aspectRatio) / prod.aspectRatio;
9819
+ if (ratioDelta >= ASPECT_RATIO_TOLERANCE) {
9820
+ const shape = (r) => r > 1.5 ? "wide" : r < 0.8 ? "tall" : "near-square";
9821
+ const prodShape = shape(prod.aspectRatio);
9822
+ const candShape = shape(cand.aspectRatio);
9823
+ const orientationFlipped = prodShape === "wide" && candShape === "tall" || prodShape === "tall" && candShape === "wide";
9824
+ out.push({
9825
+ id: `banner-aspect:ratio:${pageKey}:${index}`,
9826
+ severity: orientationFlipped ? "high" : "medium",
9827
+ category: "visual",
9828
+ page: pageKey,
9829
+ check: "banner-aspect-ratio",
9830
+ summary: `Aspect ratio divergente em ${label}: prod=${prod.width}×${prod.height} (${prod.aspectRatio.toFixed(2)}) vs cand=${cand.width}×${cand.height} (${cand.aspectRatio.toFixed(2)}) — Δ${(ratioDelta * 100).toFixed(0)}%${orientationFlipped ? ` (${prodShape} ↔ ${candShape}, provável variante mobile/desktop trocada)` : ""}`
9831
+ });
9832
+ }
9833
+ }
9834
+ return out;
9835
+ }
9836
+ function describeBanner(b) {
9837
+ if (b.sectionName)
9838
+ return `${b.sectionName} banner`;
9839
+ const fname = b.src.split("?")[0]?.split("/").pop();
9840
+ return fname ? `banner '${fname.slice(0, 40)}'` : null;
9841
+ }
9842
+
9843
+ // src/checks/cart-reveal-mode.ts
9844
+ function cartRevealModeDivergence(ctx) {
9845
+ const start = Date.now();
9846
+ const issues = [];
9847
+ let viewportsChecked = 0;
9848
+ let viewportsDivergent = 0;
9849
+ for (const viewport of ctx.viewports) {
9850
+ const prodMode = readMode(ctx.prodFlows, viewport);
9851
+ const candMode = readMode(ctx.candFlows, viewport);
9852
+ if (prodMode === null && candMode === null)
9853
+ continue;
9854
+ viewportsChecked++;
9855
+ if (prodMode === null || candMode === null) {
9856
+ continue;
9857
+ }
9858
+ if (prodMode === candMode)
9859
+ continue;
9860
+ viewportsDivergent++;
9861
+ issues.push({
9862
+ id: `cart-reveal-mode:${viewport}:divergent`,
9863
+ severity: "critical",
9864
+ category: "functional",
9865
+ check: "cart-reveal-mode-divergence",
9866
+ summary: `[${viewport}] minicart trigger divergente: prod=${prodMode}, cand=${candMode}${explainDivergence(prodMode, candMode)}`,
9867
+ details: detailFor(prodMode, candMode, viewport)
9868
+ });
9869
+ }
9870
+ const status = viewportsDivergent > 0 ? "fail" : viewportsChecked > 0 ? "pass" : "skipped";
9871
+ return {
9872
+ name: "cart-reveal-mode-divergence",
9873
+ status,
9874
+ severity: "critical",
9875
+ durationMs: Date.now() - start,
9876
+ summary: viewportsChecked === 0 ? "purchase-journey não rodou ou step 7 não classificou cart reveal mode" : `${viewportsChecked} viewport(s) checados, ${viewportsDivergent} com markup divergente`,
9877
+ issues,
9878
+ data: { viewportsChecked, viewportsDivergent }
9879
+ };
9880
+ }
9881
+ function readMode(flows, viewport) {
9882
+ const flow = flows.find((f) => f.flow === "purchase-journey" && f.viewport === viewport);
9883
+ if (!flow)
9884
+ return null;
9885
+ const step7 = flow.steps?.find((s) => s.name === "open-minicart");
9886
+ return step7?.cartRevealMode ?? null;
9887
+ }
9888
+ function explainDivergence(prod, cand) {
9889
+ const candIsNav = cand === "click-navigate-checkout" || cand === "click-navigate-cart";
9890
+ const prodIsDrawer = prod === "hover-drawer" || prod === "click-drawer" || prod === "inline-notification";
9891
+ if (candIsNav && prodIsDrawer) {
9892
+ return " — usuário em cand é levado direto pra navegação em vez de inspecionar cart inline";
9893
+ }
9894
+ const prodIsNav = prod === "click-navigate-checkout" || prod === "click-navigate-cart";
9895
+ const candIsDrawer = cand === "hover-drawer" || cand === "click-drawer" || cand === "inline-notification";
9896
+ if (prodIsNav && candIsDrawer) {
9897
+ return " — cand expõe um drawer onde prod fazia hard navigation (ganho de UX, mas mudança de fluxo)";
9898
+ }
9899
+ return "";
9900
+ }
9901
+ function detailFor(prod, cand, viewport) {
9902
+ return [
9903
+ `Viewport: ${viewport}`,
9904
+ `Prod cart reveal mode: ${prod}`,
9905
+ `Cand cart reveal mode: ${cand}`,
9906
+ "",
9907
+ "Os dois lados foram exercitados pelo step 7 (open-minicart). O markup",
9908
+ "do trigger do minicart, porém, indica intenções diferentes:",
9909
+ " - hover-drawer: trigger reveala drawer inline ao hover",
9910
+ " - click-drawer: trigger tem handler de click que abre drawer inline",
9911
+ " - click-navigate-*: trigger é link que NAVEGA pro checkout/cart",
9912
+ " - inline-notification: drawer já foi aberto pelo add-to-cart",
9913
+ "",
9914
+ "Causas comuns de divergência em migração Deco Fresh→TanStack:",
9915
+ " 1. Migração trocou o elemento <a href=...> por <button> (ou vice-versa)",
9916
+ " 2. Migração removeu o data-toggle / aria-haspopup que ativava o drawer",
9917
+ " 3. Migração desmontou o handler hover-only mas manteve o link de fallback"
9918
+ ].join(`
9919
+ `);
9920
+ }
9921
+
8820
9922
  // src/checks/lazy-sections.ts
8821
9923
  var LAZY_URL_PATTERN = /\/(deco\/render|_loader)\b/;
8822
9924
  function lazySectionPresence(ctx) {
@@ -9632,6 +10734,8 @@ var ALL_CHECKS = [
9632
10734
  networkSummaryDelta,
9633
10735
  webVitalsMobile,
9634
10736
  imageLoadingHealth,
10737
+ bannerAspectRatio,
10738
+ cartRevealModeDivergence,
9635
10739
  lazySectionPresence,
9636
10740
  seoDeepAudit,
9637
10741
  cacheCoverage
@@ -10282,7 +11386,8 @@ async function runCommand(rawOpts) {
10282
11386
  const rc = loadParityRc();
10283
11387
  rc.cep = opts.cep || rc.cep;
10284
11388
  const ignore = loadParityIgnore();
10285
- const preflight = await preflightCheck(opts.prod, opts.cand);
11389
+ const primaryViewport = viewports[0] ?? "mobile";
11390
+ const preflight = await preflightCheck(opts.prod, opts.cand, primaryViewport);
10286
11391
  if (!preflight.ok) {
10287
11392
  console.error(chalk13.red(`
10288
11393
  ✖ pre-flight falhou:`));
@@ -10295,7 +11400,7 @@ async function runCommand(rawOpts) {
10295
11400
  const learned = loadLearned();
10296
11401
  const learnedBefore = JSON.stringify(learned);
10297
11402
  let platform = "custom";
10298
- const prodHomeHtml = await fetchHomeHtml(opts.prod);
11403
+ const prodHomeHtml = await fetchHomeHtml(opts.prod, primaryViewport);
10299
11404
  if (prodHomeHtml) {
10300
11405
  platform = detectPlatform({ url: opts.prod, html: prodHomeHtml });
10301
11406
  if (platform !== "custom") {
@@ -10309,7 +11414,7 @@ async function runCommand(rawOpts) {
10309
11414
  if (wantsAutoSelectors) {
10310
11415
  const discoverSpinner = ora4("Descobrindo seletores via LLM (analisando home prod)…").start();
10311
11416
  try {
10312
- const html = prodHomeHtml ?? await fetchHomeHtml(opts.prod);
11417
+ const html = prodHomeHtml ?? await fetchHomeHtml(opts.prod, primaryViewport);
10313
11418
  if (html) {
10314
11419
  const discovered = await discoverSelectorsFromUrl(opts.prod, html, {
10315
11420
  noCache: opts.refreshSelectors === true
@@ -10353,6 +11458,23 @@ async function runCommand(rawOpts) {
10353
11458
  const allPageCaptures = [];
10354
11459
  try {
10355
11460
  browser = await launchBrowser({ headless: true });
11461
+ if (opts.warmup) {
11462
+ const warmupSpinner = ora4("Warmup: bustando cache em prod + cand…").start();
11463
+ const result = await warmupTargets({
11464
+ urls: [opts.prod, opts.cand],
11465
+ viewports,
11466
+ bypassCache: opts.bypassCache !== false
11467
+ });
11468
+ if (result.failed.length === 0) {
11469
+ warmupSpinner.succeed(`Warmup ok — ${result.succeeded}/${result.attempted} workers serviram resposta fresca`);
11470
+ } else if (result.succeeded > 0) {
11471
+ const failureSummary = result.failed.slice(0, 3).map((f) => `${f.viewport}/${hostOf(f.url)} (${f.reason})`).join("; ");
11472
+ warmupSpinner.warn(`Warmup parcial — ${result.succeeded}/${result.attempted} ok; ${result.failed.length} falha(s): ${failureSummary}`);
11473
+ } else {
11474
+ const firstReason = result.failed[0]?.reason ?? "unknown";
11475
+ warmupSpinner.fail(`Warmup falhou — 0/${result.attempted} requests ok. Cache pode estar stale. Primeira falha: ${firstReason}`);
11476
+ }
11477
+ }
10356
11478
  for (const viewport of viewports) {
10357
11479
  for (const side of ["prod", "cand"]) {
10358
11480
  const baseUrl = side === "prod" ? opts.prod : opts.cand;
@@ -10363,7 +11485,8 @@ async function runCommand(rawOpts) {
10363
11485
  viewport,
10364
11486
  harPath,
10365
11487
  tracesDir: paths.tracesDir,
10366
- cohortCookieValue: "control"
11488
+ cohortCookieValue: "control",
11489
+ noCache: opts.bypassCache
10367
11490
  });
10368
11491
  await installVitalsCollector(ctx);
10369
11492
  for (const flow of flows) {
@@ -10377,27 +11500,16 @@ async function runCommand(rawOpts) {
10377
11500
  outDir: paths.screenshotsDir,
10378
11501
  learned,
10379
11502
  platform,
10380
- recoveryBudget: 3
11503
+ recoveryBudget: 3,
11504
+ acceptProdQuirks: opts.acceptProdQuirks
10381
11505
  });
10382
11506
  allFlowCaptures.push(cap);
10383
11507
  for (const p of cap.pages)
10384
11508
  allPageCaptures.push(p);
10385
11509
  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
- }
11510
+ const result = promoteStepsFromFlow(learned, platform, prodHost, cap);
11511
+ promotedCount += result.promoted;
11512
+ deprecatedCount += result.deprecated;
10401
11513
  }
10402
11514
  }
10403
11515
  await stopTracing(ctx, tracePath).catch(() => {
@@ -10474,7 +11586,7 @@ async function runCommand(rawOpts) {
10474
11586
  const baseUrl = task.side === "prod" ? opts.prod : opts.cand;
10475
11587
  const fullUrl = new URL(task.path, baseUrl).toString();
10476
11588
  try {
10477
- const ctx = await newContext(browser, { viewport: task.viewport, cohortCookieValue: "control" });
11589
+ const ctx = await newContext(browser, { viewport: task.viewport, cohortCookieValue: "control", noCache: opts.bypassCache });
10478
11590
  await installVitalsCollector(ctx);
10479
11591
  const page = await ctx.newPage();
10480
11592
  try {
@@ -10551,7 +11663,7 @@ async function runCommand(rawOpts) {
10551
11663
  const safePath = task.path.replace(/[/?&=]+/g, "_") || "_root";
10552
11664
  const screenshotPath2 = `${paths.screenshotsDir}/visual-${safePath}-${task.viewport}-${task.side}.png`;
10553
11665
  try {
10554
- const ctx = await newContext(browser, { viewport: task.viewport, cohortCookieValue: "control" });
11666
+ const ctx = await newContext(browser, { viewport: task.viewport, cohortCookieValue: "control", noCache: opts.bypassCache });
10555
11667
  await installVitalsCollector(ctx);
10556
11668
  const page = await ctx.newPage();
10557
11669
  try {
@@ -10719,9 +11831,10 @@ async function runWithConcurrency3(items, workers, fn) {
10719
11831
  }
10720
11832
  }));
10721
11833
  }
10722
- async function preflightCheck(prodUrl, candUrl) {
11834
+ async function preflightCheck(prodUrl, candUrl, viewport) {
10723
11835
  const spinner = ora4("Pre-flight: verificando URLs…").start();
10724
11836
  const errors = [];
11837
+ const ua = userAgentFor(viewport);
10725
11838
  async function probe(label, url) {
10726
11839
  try {
10727
11840
  new URL(url);
@@ -10737,7 +11850,7 @@ async function preflightCheck(prodUrl, candUrl) {
10737
11850
  redirect: "follow",
10738
11851
  signal: controller.signal,
10739
11852
  headers: {
10740
- "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
11853
+ "User-Agent": ua
10741
11854
  }
10742
11855
  });
10743
11856
  if (res.status >= 500)
@@ -10760,11 +11873,44 @@ async function preflightCheck(prodUrl, candUrl) {
10760
11873
  spinner.succeed("Pre-flight OK");
10761
11874
  return { ok: true, errors: [] };
10762
11875
  }
10763
- async function fetchHomeHtml(url) {
11876
+ function addCacheBuster(url) {
11877
+ try {
11878
+ const u = new URL(url);
11879
+ u.searchParams.set("_pcb", String(Date.now()));
11880
+ return u.toString();
11881
+ } catch {
11882
+ return url;
11883
+ }
11884
+ }
11885
+ async function warmupTargets(opts) {
11886
+ const headers = opts.bypassCache ? { "Cache-Control": "no-cache", Pragma: "no-cache" } : {};
11887
+ const jobs = [];
11888
+ for (const url of opts.urls) {
11889
+ for (const viewport of opts.viewports) {
11890
+ const ua = userAgentFor(viewport);
11891
+ const target = addCacheBuster(url);
11892
+ jobs.push(fetch(target, {
11893
+ method: "GET",
11894
+ redirect: "follow",
11895
+ headers: { ...headers, "User-Agent": ua }
11896
+ }).then((res) => res.ok || res.status >= 200 && res.status < 400 ? { ok: true } : { ok: false, url, viewport, reason: `HTTP ${res.status} ${res.statusText}` }).catch((err) => ({
11897
+ ok: false,
11898
+ url,
11899
+ viewport,
11900
+ reason: err.message ?? "fetch failed"
11901
+ })));
11902
+ }
11903
+ }
11904
+ const results = await Promise.all(jobs);
11905
+ const succeeded = results.filter((r) => r.ok).length;
11906
+ const failed = results.filter((r) => !r.ok).map(({ url, viewport, reason }) => ({ url, viewport, reason }));
11907
+ return { attempted: results.length, succeeded, failed };
11908
+ }
11909
+ async function fetchHomeHtml(url, viewport = "desktop") {
10764
11910
  try {
10765
11911
  const res = await fetch(url, {
10766
11912
  headers: {
10767
- "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
11913
+ "User-Agent": userAgentFor(viewport),
10768
11914
  Accept: "text/html,application/xhtml+xml",
10769
11915
  "Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8"
10770
11916
  },
@@ -10824,7 +11970,7 @@ function printSummary4(run, htmlPath, meta) {
10824
11970
  // src/cli.ts
10825
11971
  var program = new Command;
10826
11972
  program.name("parity").description("E2E parity validator for Fresh -> TanStack site migrations").version("0.0.0");
10827
- program.command("run").description("Compare two URLs and produce a parity report").requiredOption("--prod <url>", "Production URL (source of truth, e.g. Fresh site)").requiredOption("--cand <url>", "Candidate URL (migrated site, e.g. TanStack)").option("--preset <name>", "Bundle of defaults: smoke (fast, ~30s, no LLM) | full (deep audit) | ci (CI-tuned)").option("--flows <list>", "Comma-separated flows: homepage,plp,pdp,purchase-journey", "purchase-journey").option("--viewports <list>", "Comma-separated viewports: mobile,desktop", "mobile,desktop").option("--cep <cep>", "CEP for shipping calculation", "01310-100").option("--runs <n>", "Repeat each measurement N times (median)", "1").option("--baseline <name>", "Compare against a saved baseline").option("--output <dir>", "Output directory", "./parity-output").option("--ci", "CI mode: stricter exit codes", false).option("--fail-on <severities>", "Comma-separated severities that cause exit 1", "critical").option("--open", "Open the HTML report after the run completes", false).option("--no-auto-selectors", "Disable LLM-based selector discovery (uses defaults instead)").option("--refresh-selectors", "Bypass selector cache and re-run discovery", false).option("--no-learn", "Don't write to learned-selectors.json (read-only mode)").option("--vitals-pages <n>", "Extra pages from sitemap to crawl for Vitals coverage (default 10)", (v) => Number(v), 10).option("--visual-pages <n>", "Pages to compare visually via LLM (home + sampled PLPs/PDPs from sitemap, default 5)", (v) => Number(v), 5).option("--no-visual-diff", "Skip the visual diff capture pass entirely").action(async (opts) => {
11973
+ program.command("run").description("Compare two URLs and produce a parity report").requiredOption("--prod <url>", "Production URL (source of truth, e.g. Fresh site)").requiredOption("--cand <url>", "Candidate URL (migrated site, e.g. TanStack)").option("--preset <name>", "Bundle of defaults: smoke (fast, ~30s, no LLM) | full (deep audit) | ci (CI-tuned)").option("--flows <list>", "Comma-separated flows: homepage,plp,pdp,purchase-journey", "purchase-journey").option("--viewports <list>", "Comma-separated viewports: mobile,desktop", "mobile,desktop").option("--cep <cep>", "CEP for shipping calculation", "01310-100").option("--runs <n>", "Repeat each measurement N times (median)", "1").option("--baseline <name>", "Compare against a saved baseline").option("--output <dir>", "Output directory", "./parity-output").option("--ci", "CI mode: stricter exit codes", false).option("--fail-on <severities>", "Comma-separated severities that cause exit 1", "critical").option("--open", "Open the HTML report after the run completes", false).option("--no-auto-selectors", "Disable LLM-based selector discovery (uses defaults instead)").option("--refresh-selectors", "Bypass selector cache and re-run discovery", false).option("--no-learn", "Don't write to learned-selectors.json (read-only mode)").option("--vitals-pages <n>", "Extra pages from sitemap to crawl for Vitals coverage (default 10)", (v) => Number(v), 10).option("--visual-pages <n>", "Pages to compare visually via LLM (home + sampled PLPs/PDPs from sitemap, default 5)", (v) => Number(v), 5).option("--no-visual-diff", "Skip the visual diff capture pass entirely").option("--bypass-cache", "Bypass CDN/edge caches: append a cache-busting query param and send Cache-Control: no-cache on every request. Use right after a deploy to avoid false failures from stale CF edge content.", false).option("--warmup", "Before measurement, hit each target URL once (per viewport) with a cache-buster so the Worker serves a fresh response. Recommended after deploys.", false).option("--accept-prod-quirks", "Demote prod-side cart-empty journey failures (VTEX session quirk) from failed to skipped. The cart-reveal-mode-divergence check still emits critical if prod/cand markup intents differ, so this flag never masks a real regression. See issue #12.", false).action(async (opts) => {
10828
11974
  const code = await runCommand(opts);
10829
11975
  process.exit(code);
10830
11976
  });
@@ -10853,7 +11999,7 @@ program.command("cache").description("Análise de cache focada em cand. Crawla N
10853
11999
  program.command("vitals").description("Crawleia múltiplas páginas (via sitemap.xml ou --urls) e compara Web Vitals prod vs cand em cada uma. Output HTML com top piores/melhores expandidos.").requiredOption("--prod <url>", "Production URL (base)").requiredOption("--cand <url>", "Candidate URL (base)").option("--urls <list-or-file>", "Comma-separated paths or .txt file (1/line). Overrides sitemap discovery.").option("--limit <n>", "Max pages discovered from sitemap.xml", (v) => Number(v), 20).option("--viewports <list>", "mobile,desktop", "mobile").option("--concurrency <n>", "Parallel workers (1-8)", (v) => Number(v), 4).option("--output <dir>", "Output directory", "./parity-output").option("--open", "Open the HTML report when done", false).action(async (opts) => {
10854
12000
  process.exit(await vitalsCommand(opts));
10855
12001
  });
10856
- program.command("journey").description("CI-friendly: roda só o purchase-journey (home → PLP → PDP → frete → carrinho → frete carrinho → checkout) e compara prod vs cand step-by-step").requiredOption("--prod <url>", "Production URL").requiredOption("--cand <url>", "Candidate URL").option("--viewports <list>", "mobile,desktop", "mobile").option("--cep <cep>", "CEP for shipping calc", "01310-100").option("--output <dir>", "Output directory for runs/<id>/", "./parity-output").option("--junit <file>", "Write JUnit XML to this path").option("--github", "Emit GitHub Actions annotations (::error, ::warning) for failures").option("--json", "Emit a one-line JSON status object to stdout (machine-readable)").option("--no-report", "Skip writing report.html / report.json").option("--no-auto-selectors", "Skip LLM-based selector discovery").action(async (opts) => {
12002
+ program.command("journey").description("CI-friendly: roda só o purchase-journey (home → PLP → PDP → frete → carrinho → frete carrinho → checkout) e compara prod vs cand step-by-step").requiredOption("--prod <url>", "Production URL").requiredOption("--cand <url>", "Candidate URL").option("--viewports <list>", "mobile,desktop", "mobile").option("--cep <cep>", "CEP for shipping calc", "01310-100").option("--output <dir>", "Output directory for runs/<id>/", "./parity-output").option("--junit <file>", "Write JUnit XML to this path").option("--github", "Emit GitHub Actions annotations (::error, ::warning) for failures").option("--json", "Emit a one-line JSON status object to stdout (machine-readable)").option("--no-report", "Skip writing report.html / report.json").option("--no-auto-selectors", "Skip LLM-based selector discovery").option("--accept-prod-quirks", "Demote prod-side cart-empty journey failures (VTEX session quirk) from failed to skipped. The cart-reveal-mode-divergence check still emits critical if prod/cand markup intents differ, so this flag never masks a real regression. See issue #12.", false).action(async (opts) => {
10857
12003
  process.exit(await journeyCommand(opts));
10858
12004
  });
10859
12005
  program.command("prompt").argument("<runId>", "Run ID").description("Generate a Markdown prompt of ranked issues ready to paste into any LLM").option("--output <dir>", "Output directory where runs live", "./parity-output").option("--out <file>", "Write to file instead of stdout").option("--min-severity <sev>", "Only include issues at or above this severity (critical|high|medium|low)", "low").option("--limit <n>", "Cap the number of issues included", (v) => Number(v), 20).action((runId, opts) => {