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