@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.
- package/dist/cli.js +600 -66
- 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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
5830
|
-
if (alreadyOpen) {
|
|
5994
|
+
if (drawerAlreadyOpen) {
|
|
5831
5995
|
cartOpenMethod = "already-open";
|
|
5832
|
-
|
|
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
|
|
5858
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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":
|
|
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
|
|
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":
|
|
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
|
-
|
|
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":
|
|
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.
|
|
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",
|