@decocms/parity 0.5.4 → 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 (2) hide show
  1. package/dist/cli.js +600 -66
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -91,7 +91,15 @@ var StepCapture = z.object({
91
91
  observedTitles: z.array(z.string()).optional(),
92
92
  reason: z.string().optional()
93
93
  }).optional(),
94
- 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()
95
103
  });
96
104
  var FlowCapture = z.object({
97
105
  flow: FlowName,
@@ -864,6 +872,138 @@ function diffSitemap(prodUrls, candUrls) {
864
872
 
865
873
  // src/engine/browser.ts
866
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
867
1007
  var DISABLE_ANIMATIONS_CSS = `
868
1008
  *, *::before, *::after {
869
1009
  animation-duration: 0s !important;
@@ -873,17 +1013,33 @@ var DISABLE_ANIMATIONS_CSS = `
873
1013
  scroll-behavior: auto !important;
874
1014
  }
875
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
+ }
876
1024
  var VIEWPORT_PRESETS = {
877
1025
  mobile: {
878
- ...devices["Pixel 7"]
1026
+ ...devices["Pixel 7"],
1027
+ userAgent: USER_AGENT_BY_VIEWPORT.mobile,
1028
+ isMobile: true,
1029
+ hasTouch: true
879
1030
  },
880
1031
  tablet: {
881
- ...devices["iPad Mini"]
1032
+ ...devices["iPad Mini"],
1033
+ userAgent: USER_AGENT_BY_VIEWPORT.tablet,
1034
+ isMobile: true,
1035
+ hasTouch: true
882
1036
  },
883
1037
  desktop: {
884
1038
  viewport: { width: 1440, height: 900 },
885
1039
  deviceScaleFactor: 1,
886
- 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
887
1043
  }
888
1044
  };
889
1045
  async function launchBrowser(opts = {}) {
@@ -899,7 +1055,8 @@ async function newContext(browser, opts) {
899
1055
  ...baseContext,
900
1056
  recordHar: opts.harPath ? { path: opts.harPath, mode: "minimal" } : undefined,
901
1057
  bypassCSP: true,
902
- ignoreHTTPSErrors: true
1058
+ ignoreHTTPSErrors: true,
1059
+ extraHTTPHeaders: opts.noCache ? { "Cache-Control": "no-cache", Pragma: "no-cache" } : undefined
903
1060
  });
904
1061
  await ctx.addInitScript({
905
1062
  content: `
@@ -910,6 +1067,7 @@ async function newContext(browser, opts) {
910
1067
  } catch (e) {}
911
1068
  `
912
1069
  });
1070
+ await ctx.addInitScript({ content: CAROUSEL_STABILIZER_INIT_SCRIPT });
913
1071
  if (opts.cohortCookieValue) {
914
1072
  await ctx.addCookies([
915
1073
  {
@@ -1199,6 +1357,12 @@ async function capturePage(page, opts) {
1199
1357
  new Promise((resolve) => setTimeout(() => resolve(null), Math.min(5000, remaining())))
1200
1358
  ]) ?? null;
1201
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
+ ]);
1202
1366
  dlog(opts.side, opts.viewport, ` capturePage: screenshot start (cap=${Math.min(15000, remaining())}ms)`);
1203
1367
  await Promise.race([
1204
1368
  page.screenshot({ path: opts.screenshotPath, fullPage: true, animations: "disabled" }).catch(() => {
@@ -5277,6 +5441,17 @@ function isResolutionContext(v) {
5277
5441
  }
5278
5442
 
5279
5443
  // src/engine/flows.ts
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
+ }
5280
5455
  var PURCHASE_JOURNEY_TOTAL_STEPS = 9;
5281
5456
  var DEBUG_PARITY2 = process.env.DEBUG_PARITY === "1" || process.env.DEBUG_PARITY === "true";
5282
5457
  var DEBUG_START2 = Date.now();
@@ -5616,9 +5791,7 @@ async function flowPurchaseJourney(ctx) {
5616
5791
  const t4 = Date.now();
5617
5792
  const beforeUrl4 = page.url();
5618
5793
  const spBefore4 = screenshotPath(ctx, "pj-4-select-variant-before");
5619
- await page.screenshot({ path: spBefore4, fullPage: false }).catch(() => {
5620
- return;
5621
- });
5794
+ await screenshotStable(page, { path: spBefore4, fullPage: false });
5622
5795
  const variantResult = await selectVariant(page, ctx);
5623
5796
  let variantLlmAction = null;
5624
5797
  if (variantResult.actions.length === 0 && variantResult.variantRequired && budget.remaining > 0) {
@@ -5633,9 +5806,7 @@ async function flowPurchaseJourney(ctx) {
5633
5806
  });
5634
5807
  }
5635
5808
  const sp4 = screenshotPath(ctx, "pj-4-select-variant");
5636
- await page.screenshot({ path: sp4, fullPage: false }).catch(() => {
5637
- return;
5638
- });
5809
+ await screenshotStable(page, { path: sp4, fullPage: false });
5639
5810
  if (variantResult.actions.length > 0 || variantLlmAction?.performed) {
5640
5811
  const llmDesc = variantLlmAction?.performed ? `Recovery LLM: ${variantLlmAction.action} em \`${variantLlmAction.selector}\`` : null;
5641
5812
  const desc = variantResult.actions.length > 0 ? variantResult.actions.join("; ") : llmDesc ?? "(variante selecionada)";
@@ -5688,14 +5859,10 @@ async function flowPurchaseJourney(ctx) {
5688
5859
  const t5 = Date.now();
5689
5860
  const beforeUrl5 = page.url();
5690
5861
  const spBefore5 = screenshotPath(ctx, "pj-5-shipping-pdp-before");
5691
- await page.screenshot({ path: spBefore5, fullPage: false }).catch(() => {
5692
- return;
5693
- });
5862
+ await screenshotStable(page, { path: spBefore5, fullPage: false });
5694
5863
  const ok = await fillCep(page, cepInputPdp, ctx.rc.cep);
5695
5864
  const sp = screenshotPath(ctx, "pj-5-shipping-pdp");
5696
- await page.screenshot({ path: sp, fullPage: false }).catch(() => {
5697
- return;
5698
- });
5865
+ await screenshotStable(page, { path: sp, fullPage: false });
5699
5866
  const step5Status = ok ? "ok" : "failed";
5700
5867
  steps.push({
5701
5868
  step: 5,
@@ -5738,9 +5905,7 @@ async function flowPurchaseJourney(ctx) {
5738
5905
  const t6 = Date.now();
5739
5906
  const beforeUrl6 = page.url();
5740
5907
  const spBefore6 = screenshotPath(ctx, "pj-6-add-cart-before");
5741
- await page.screenshot({ path: spBefore6, fullPage: false }).catch(() => {
5742
- return;
5743
- });
5908
+ await screenshotStable(page, { path: spBefore6, fullPage: false });
5744
5909
  const cartCountBefore = await readCartCount(page, ctx);
5745
5910
  const buyText = await buyHit.locator.innerText().catch(() => "");
5746
5911
  await buyHit.locator.click({ timeout: 5000 }).catch(() => {
@@ -5775,9 +5940,7 @@ async function flowPurchaseJourney(ctx) {
5775
5940
  }
5776
5941
  }
5777
5942
  const sp6 = screenshotPath(ctx, "pj-6-add-cart");
5778
- await page.screenshot({ path: sp6, fullPage: false }).catch(() => {
5779
- return;
5780
- });
5943
+ await screenshotStable(page, { path: sp6, fullPage: false });
5781
5944
  const buyActionDesc = `Clicou no botão${buyText ? ` '${buyText.slice(0, 40).trim()}'` : ""} (\`${buyHit.selector}\`)${buyRecovered ? " — selector veio de recovery LLM" : ""}`;
5782
5945
  const fullActionDesc = variantRetryNote ? `${buyActionDesc} — ${variantRetryNote} — ${validation.note}` : `${buyActionDesc} — ${validation.note}`;
5783
5946
  steps.push({
@@ -5806,9 +5969,7 @@ async function flowPurchaseJourney(ctx) {
5806
5969
  const t7 = Date.now();
5807
5970
  const beforeUrl7 = page.url();
5808
5971
  const spBefore7 = screenshotPath(ctx, "pj-7-minicart-before");
5809
- await page.screenshot({ path: spBefore7, fullPage: false }).catch(() => {
5810
- return;
5811
- });
5972
+ await screenshotStable(page, { path: spBefore7, fullPage: false });
5812
5973
  let miniHit = await firstVisibleLocator(page, selFor(ctx, "minicartTrigger"));
5813
5974
  let miniRecovered = false;
5814
5975
  if (!miniHit && budget.remaining > 0) {
@@ -5821,15 +5982,19 @@ async function flowPurchaseJourney(ctx) {
5821
5982
  }
5822
5983
  let cartOpenMethod = "failed";
5823
5984
  let miniText = "";
5985
+ let cartRevealMode = "unknown";
5986
+ const drawerAlreadyOpen = await isCartRevealed(page, expectedProductTitle) !== null;
5824
5987
  if (miniHit) {
5825
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}`);
5826
5991
  const openResult = await openMinicart(page, miniHit, ctx, expectedProductTitle);
5827
5992
  cartOpenMethod = openResult.method;
5828
5993
  } else {
5829
- const alreadyOpen = await isCartRevealed(page, expectedProductTitle);
5830
- if (alreadyOpen) {
5994
+ if (drawerAlreadyOpen) {
5831
5995
  cartOpenMethod = "already-open";
5832
- dlog2(ctx, `step 7 open-minicart: no trigger, drawer already visible (${alreadyOpen})`);
5996
+ cartRevealMode = "inline-notification";
5997
+ dlog2(ctx, "step 7 open-minicart: no trigger, drawer already visible");
5833
5998
  }
5834
5999
  }
5835
6000
  let step7Validation;
@@ -5854,10 +6019,11 @@ async function flowPurchaseJourney(ctx) {
5854
6019
  dlog2(ctx, `step 7 open-minicart: validation → found=${v.found} (${v.method})${reasonText ? ` — ${reasonText.slice(0, 80)}` : ""}`);
5855
6020
  }
5856
6021
  const sp7 = screenshotPath(ctx, "pj-7-minicart");
5857
- await page.screenshot({ path: sp7, fullPage: false }).catch(() => {
5858
- return;
5859
- });
5860
- const step7Status = cartOpenMethod === "failed" ? "failed" : step7Validation && !step7Validation.found ? "failed" : "ok";
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;
5861
6027
  steps.push({
5862
6028
  step: 7,
5863
6029
  name: "open-minicart",
@@ -5870,13 +6036,25 @@ async function flowPurchaseJourney(ctx) {
5870
6036
  screenshotBeforePath: spBefore7,
5871
6037
  beforeUrl: beforeUrl7,
5872
6038
  cartOpenMethod,
6039
+ cartRevealMode,
5873
6040
  cartValidation: step7Validation,
6041
+ note: step7QuirkNote,
5874
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",
5875
6043
  selectorKey: miniHit ? "minicartTrigger" : undefined,
5876
6044
  usedSelector: miniHit?.selector,
5877
6045
  recoveredByLlm: miniRecovered || undefined
5878
6046
  });
5879
- reportEnd(7, "open-minicart", step7Status, Date.now() - t7);
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
+ }
5880
6058
  reportStart(8, "shipping-calc-cart");
5881
6059
  let cepInputCart = await firstVisible(page, selFor(ctx, "cepInputCart"));
5882
6060
  let cepCartRecoveredByLlm = false;
@@ -5903,14 +6081,10 @@ async function flowPurchaseJourney(ctx) {
5903
6081
  const t8 = Date.now();
5904
6082
  const beforeUrl8 = page.url();
5905
6083
  const spBefore8 = screenshotPath(ctx, "pj-8-shipping-cart-before");
5906
- await page.screenshot({ path: spBefore8, fullPage: false }).catch(() => {
5907
- return;
5908
- });
6084
+ await screenshotStable(page, { path: spBefore8, fullPage: false });
5909
6085
  const ok = await fillCep(page, cepInputCart, ctx.rc.cep);
5910
6086
  const sp8 = screenshotPath(ctx, "pj-8-shipping-cart");
5911
- await page.screenshot({ path: sp8, fullPage: false }).catch(() => {
5912
- return;
5913
- });
6087
+ await screenshotStable(page, { path: sp8, fullPage: false });
5914
6088
  const step8Status = ok ? "ok" : "failed";
5915
6089
  steps.push({
5916
6090
  step: 8,
@@ -5951,9 +6125,7 @@ async function flowPurchaseJourney(ctx) {
5951
6125
  };
5952
6126
  }
5953
6127
  const sp9early = screenshotPath(ctx, "pj-9-checkout-reached");
5954
- await page.screenshot({ path: sp9early, fullPage: false }).catch(() => {
5955
- return;
5956
- });
6128
+ await screenshotStable(page, { path: sp9early, fullPage: false });
5957
6129
  const earlyStatus = step9EarlyValidation && !step9EarlyValidation.found ? "failed" : "ok";
5958
6130
  steps.push({
5959
6131
  step: 9,
@@ -5987,9 +6159,7 @@ async function flowPurchaseJourney(ctx) {
5987
6159
  return { pages, steps };
5988
6160
  }
5989
6161
  const spBefore9 = screenshotPath(ctx, "pj-9-checkout-before");
5990
- await page.screenshot({ path: spBefore9, fullPage: false }).catch(() => {
5991
- return;
5992
- });
6162
+ await screenshotStable(page, { path: spBefore9, fullPage: false });
5993
6163
  let usedSelector = checkoutHit.selector;
5994
6164
  let clickedText = await checkoutHit.locator.innerText().catch(() => "");
5995
6165
  let reachedCheckout = false;
@@ -6036,9 +6206,7 @@ async function flowPurchaseJourney(ctx) {
6036
6206
  };
6037
6207
  }
6038
6208
  const sp9 = screenshotPath(ctx, "pj-9-checkout-reached");
6039
- await page.screenshot({ path: sp9, fullPage: false }).catch(() => {
6040
- return;
6041
- });
6209
+ await screenshotStable(page, { path: sp9, fullPage: false });
6042
6210
  const step9Status = !reachedCheckout ? "failed" : step9Validation && !step9Validation.found ? "failed" : "ok";
6043
6211
  steps.push({
6044
6212
  step: 9,
@@ -6610,6 +6778,80 @@ async function isCartUiVisible(page) {
6610
6778
  "[class*='drawer-cart']:visible"
6611
6779
  ]);
6612
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
+ }
6613
6855
  async function isCartRevealed(page, expectedProductTitle) {
6614
6856
  if (expectedProductTitle) {
6615
6857
  const v = await validateCartContainsTitleQuick(page, expectedProductTitle);
@@ -7303,7 +7545,7 @@ async function journeyCommand(opts) {
7303
7545
  let platform = "custom";
7304
7546
  try {
7305
7547
  const homeRes = await fetch(opts.prod, {
7306
- headers: { "User-Agent": "Mozilla/5.0 parity-cli" }
7548
+ headers: { "User-Agent": userAgentFor(viewports[0] ?? "mobile") }
7307
7549
  });
7308
7550
  if (homeRes.ok) {
7309
7551
  const html = await homeRes.text();
@@ -7372,7 +7614,8 @@ async function journeyCommand(opts) {
7372
7614
  learned,
7373
7615
  platform,
7374
7616
  recoveryBudget: 3,
7375
- onStep: onStepFor(viewport, side)
7617
+ onStep: onStepFor(viewport, side),
7618
+ acceptProdQuirks: opts.acceptProdQuirks
7376
7619
  });
7377
7620
  return cap;
7378
7621
  } finally {
@@ -8523,6 +8766,17 @@ function normalizeUrl(rawUrl) {
8523
8766
  }
8524
8767
 
8525
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
+ }
8526
8780
  function snapshotDom(html) {
8527
8781
  const $ = cheerio5.load(html);
8528
8782
  const counts = {
@@ -8570,12 +8824,29 @@ function snapshotDom(html) {
8570
8824
  jsonLdTypes: jsonLdTypes.sort()
8571
8825
  };
8572
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
+ });
8573
8843
  const imageStats = {
8574
8844
  total: imgs.length,
8575
8845
  withSrcset: imgs.filter((_, el) => Boolean($(el).attr("srcset"))).length,
8576
8846
  withAlt: imgs.filter((_, el) => Boolean($(el).attr("alt"))).length,
8577
8847
  withoutAlt: imgs.filter((_, el) => !$(el).attr("alt")).length,
8578
- src: imgs.map((_, el) => $(el).attr("src") ?? "").get().filter(Boolean)
8848
+ src: imgs.map((_, el) => $(el).attr("src") ?? "").get().filter(Boolean),
8849
+ banners
8579
8850
  };
8580
8851
  const decoSectionsRendered = [];
8581
8852
  $("[data-section]").each((_, el) => {
@@ -8894,6 +9165,9 @@ function buildContextBlock(input) {
8894
9165
  if (input.sectionsOnlyInProd && input.sectionsOnlyInProd.length > 0) {
8895
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.");
8896
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
+ }
8897
9171
  if (input.prodSections && input.prodSections.length > 0) {
8898
9172
  lines.push("", `Total sections em prod: ${input.prodSections.length}; em cand: ${input.candSections?.length ?? 0}.`);
8899
9173
  }
@@ -8952,6 +9226,32 @@ function pathFromKey(key) {
8952
9226
  function severityForDiff(d) {
8953
9227
  return d.severity;
8954
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
+ }
8955
9255
  async function visualRegressionKeyframes(ctx) {
8956
9256
  const start = Date.now();
8957
9257
  const { pairs } = pairCaptures(ctx.prodPages, ctx.candPages);
@@ -8995,6 +9295,7 @@ async function visualRegressionKeyframes(ctx) {
8995
9295
  const prodSet = new Set(prodSections);
8996
9296
  const sectionsOnlyInProd = prodSections.filter((s) => !candSet.has(s));
8997
9297
  const sectionsOnlyInCand = candSections.filter((s) => !prodSet.has(s));
9298
+ const bothHaveCarousel = hasCarouselSection(prodSections) && hasCarouselSection(candSections);
8998
9299
  const trivial = pctDiff < PASS_PCT_THRESHOLD && sectionsOnlyInProd.length === 0;
8999
9300
  let differences = [];
9000
9301
  let llmCalled = false;
@@ -9010,13 +9311,17 @@ async function visualRegressionKeyframes(ctx) {
9010
9311
  viewport: pair.viewport,
9011
9312
  prodSections,
9012
9313
  candSections,
9013
- sectionsOnlyInProd
9314
+ sectionsOnlyInProd,
9315
+ bothHaveCarousel
9014
9316
  });
9015
9317
  differences = diffs ?? [];
9016
9318
  } catch (err) {
9017
9319
  llmError = err.message;
9018
9320
  }
9019
9321
  }
9322
+ if (differences.length > 0) {
9323
+ differences = downgradeCarouselFramingDiffs(differences, bothHaveCarousel);
9324
+ }
9020
9325
  let verdict;
9021
9326
  const hasCritical = differences.some((d) => d.severity === "critical" || d.severity === "high");
9022
9327
  const hasAnyDiff = differences.length > 0 || sectionsOnlyInProd.length > 0;
@@ -9441,6 +9746,179 @@ function countFailedImageRequests(entries) {
9441
9746
  return entries.filter((e) => e.resourceType === "image" && (e.status === 0 || e.status >= 400)).length;
9442
9747
  }
9443
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
+
9444
9922
  // src/checks/lazy-sections.ts
9445
9923
  var LAZY_URL_PATTERN = /\/(deco\/render|_loader)\b/;
9446
9924
  function lazySectionPresence(ctx) {
@@ -10256,6 +10734,8 @@ var ALL_CHECKS = [
10256
10734
  networkSummaryDelta,
10257
10735
  webVitalsMobile,
10258
10736
  imageLoadingHealth,
10737
+ bannerAspectRatio,
10738
+ cartRevealModeDivergence,
10259
10739
  lazySectionPresence,
10260
10740
  seoDeepAudit,
10261
10741
  cacheCoverage
@@ -10906,7 +11386,8 @@ async function runCommand(rawOpts) {
10906
11386
  const rc = loadParityRc();
10907
11387
  rc.cep = opts.cep || rc.cep;
10908
11388
  const ignore = loadParityIgnore();
10909
- const preflight = await preflightCheck(opts.prod, opts.cand);
11389
+ const primaryViewport = viewports[0] ?? "mobile";
11390
+ const preflight = await preflightCheck(opts.prod, opts.cand, primaryViewport);
10910
11391
  if (!preflight.ok) {
10911
11392
  console.error(chalk13.red(`
10912
11393
  ✖ pre-flight falhou:`));
@@ -10919,7 +11400,7 @@ async function runCommand(rawOpts) {
10919
11400
  const learned = loadLearned();
10920
11401
  const learnedBefore = JSON.stringify(learned);
10921
11402
  let platform = "custom";
10922
- const prodHomeHtml = await fetchHomeHtml(opts.prod);
11403
+ const prodHomeHtml = await fetchHomeHtml(opts.prod, primaryViewport);
10923
11404
  if (prodHomeHtml) {
10924
11405
  platform = detectPlatform({ url: opts.prod, html: prodHomeHtml });
10925
11406
  if (platform !== "custom") {
@@ -10933,7 +11414,7 @@ async function runCommand(rawOpts) {
10933
11414
  if (wantsAutoSelectors) {
10934
11415
  const discoverSpinner = ora4("Descobrindo seletores via LLM (analisando home prod)…").start();
10935
11416
  try {
10936
- const html = prodHomeHtml ?? await fetchHomeHtml(opts.prod);
11417
+ const html = prodHomeHtml ?? await fetchHomeHtml(opts.prod, primaryViewport);
10937
11418
  if (html) {
10938
11419
  const discovered = await discoverSelectorsFromUrl(opts.prod, html, {
10939
11420
  noCache: opts.refreshSelectors === true
@@ -10977,6 +11458,23 @@ async function runCommand(rawOpts) {
10977
11458
  const allPageCaptures = [];
10978
11459
  try {
10979
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
+ }
10980
11478
  for (const viewport of viewports) {
10981
11479
  for (const side of ["prod", "cand"]) {
10982
11480
  const baseUrl = side === "prod" ? opts.prod : opts.cand;
@@ -10987,7 +11485,8 @@ async function runCommand(rawOpts) {
10987
11485
  viewport,
10988
11486
  harPath,
10989
11487
  tracesDir: paths.tracesDir,
10990
- cohortCookieValue: "control"
11488
+ cohortCookieValue: "control",
11489
+ noCache: opts.bypassCache
10991
11490
  });
10992
11491
  await installVitalsCollector(ctx);
10993
11492
  for (const flow of flows) {
@@ -11001,7 +11500,8 @@ async function runCommand(rawOpts) {
11001
11500
  outDir: paths.screenshotsDir,
11002
11501
  learned,
11003
11502
  platform,
11004
- recoveryBudget: 3
11503
+ recoveryBudget: 3,
11504
+ acceptProdQuirks: opts.acceptProdQuirks
11005
11505
  });
11006
11506
  allFlowCaptures.push(cap);
11007
11507
  for (const p of cap.pages)
@@ -11086,7 +11586,7 @@ async function runCommand(rawOpts) {
11086
11586
  const baseUrl = task.side === "prod" ? opts.prod : opts.cand;
11087
11587
  const fullUrl = new URL(task.path, baseUrl).toString();
11088
11588
  try {
11089
- const ctx = await newContext(browser, { viewport: task.viewport, cohortCookieValue: "control" });
11589
+ const ctx = await newContext(browser, { viewport: task.viewport, cohortCookieValue: "control", noCache: opts.bypassCache });
11090
11590
  await installVitalsCollector(ctx);
11091
11591
  const page = await ctx.newPage();
11092
11592
  try {
@@ -11163,7 +11663,7 @@ async function runCommand(rawOpts) {
11163
11663
  const safePath = task.path.replace(/[/?&=]+/g, "_") || "_root";
11164
11664
  const screenshotPath2 = `${paths.screenshotsDir}/visual-${safePath}-${task.viewport}-${task.side}.png`;
11165
11665
  try {
11166
- const ctx = await newContext(browser, { viewport: task.viewport, cohortCookieValue: "control" });
11666
+ const ctx = await newContext(browser, { viewport: task.viewport, cohortCookieValue: "control", noCache: opts.bypassCache });
11167
11667
  await installVitalsCollector(ctx);
11168
11668
  const page = await ctx.newPage();
11169
11669
  try {
@@ -11331,9 +11831,10 @@ async function runWithConcurrency3(items, workers, fn) {
11331
11831
  }
11332
11832
  }));
11333
11833
  }
11334
- async function preflightCheck(prodUrl, candUrl) {
11834
+ async function preflightCheck(prodUrl, candUrl, viewport) {
11335
11835
  const spinner = ora4("Pre-flight: verificando URLs…").start();
11336
11836
  const errors = [];
11837
+ const ua = userAgentFor(viewport);
11337
11838
  async function probe(label, url) {
11338
11839
  try {
11339
11840
  new URL(url);
@@ -11349,7 +11850,7 @@ async function preflightCheck(prodUrl, candUrl) {
11349
11850
  redirect: "follow",
11350
11851
  signal: controller.signal,
11351
11852
  headers: {
11352
- "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
11353
11854
  }
11354
11855
  });
11355
11856
  if (res.status >= 500)
@@ -11372,11 +11873,44 @@ async function preflightCheck(prodUrl, candUrl) {
11372
11873
  spinner.succeed("Pre-flight OK");
11373
11874
  return { ok: true, errors: [] };
11374
11875
  }
11375
- 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") {
11376
11910
  try {
11377
11911
  const res = await fetch(url, {
11378
11912
  headers: {
11379
- "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),
11380
11914
  Accept: "text/html,application/xhtml+xml",
11381
11915
  "Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8"
11382
11916
  },
@@ -11436,7 +11970,7 @@ function printSummary4(run, htmlPath, meta) {
11436
11970
  // src/cli.ts
11437
11971
  var program = new Command;
11438
11972
  program.name("parity").description("E2E parity validator for Fresh -> TanStack site migrations").version("0.0.0");
11439
- 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) => {
11440
11974
  const code = await runCommand(opts);
11441
11975
  process.exit(code);
11442
11976
  });
@@ -11465,7 +11999,7 @@ program.command("cache").description("Análise de cache focada em cand. Crawla N
11465
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) => {
11466
12000
  process.exit(await vitalsCommand(opts));
11467
12001
  });
11468
- 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) => {
11469
12003
  process.exit(await journeyCommand(opts));
11470
12004
  });
11471
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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/parity",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "description": "E2E parity validator for site migrations. Compares prod vs cand and reports UI, functional, SEO, visual, and Web Vitals deltas with an LLM-ranked HTML report.",
5
5
  "type": "module",
6
6
  "license": "MIT",