@decocms/parity 0.2.0 → 0.4.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 +57 -0
- package/dist/cli.js +843 -69
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,63 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
7
7
|
|
|
8
|
+
## [0.4.0](https://github.com/decocms/parity/compare/v0.3.0...v0.4.0) (2026-05-27)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
* **journey:** extract product title on PDP and validate the same product appears in cart drawer (step 6) and on checkout page (step 8). 30+ selectors cover VTEX legacy, checkout6, FastStore, Wake. ([PR #11](https://github.com/decocms/parity/pull/11))
|
|
14
|
+
* **journey:** viewport-aware minicart open strategy — desktop tries hover first (popup-style minicarts on VTEX prod), mobile tries `tap()` first (real touch event bypasses overlay handlers that swallow synthetic clicks). Adds `force: true` click + goto-href fallback when interactive strategies fail.
|
|
15
|
+
* **journey:** `dismissOverlays` actively closes cookie banners, add-to-cart toasts and `[role=alertdialog]` before interacting with the minicart trigger.
|
|
16
|
+
* **journey:** `waitForCartHydration` waits for the orderForm XHR + first cart-item selector before validation runs after `page.goto('/checkout/#/cart')`.
|
|
17
|
+
* **journey:** step 8 advance-checkout mode — when URL is already on a checkout subpage, prepends 15 next-step selectors (`#cart-to-orderform`, `a.orange-btn`, `:has-text('Continuar para pagamento')`, etc.) and waits for URL change instead of a `/checkout` match.
|
|
18
|
+
* **journey:** empty-cart banner detection populates `step.cartValidation.reason` to distinguish "cart genuinely empty (session not persisting)" from "selectors don't match markup".
|
|
19
|
+
* **journey:** `DEBUG_PARITY=1` env var enables structured per-step + per-substep dlog output to stderr.
|
|
20
|
+
* **schema:** `StepCapture.cartValidation` (expectedTitle, found, method, observedTitles, reason) and `cartOpenMethod` (click | click-navigate | hover | already-open | failed) for report traceability.
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
* **journey:** `collectCandidateLinks` 15s budget + per-op `withCap` race — prevents indefinite hang when the page V8 main thread is wedged by memory leaks (CDP messages queue past `locator.count()` declared timeout).
|
|
26
|
+
* **journey:** page-close cap of 5s in the flow timeout cleanup — closes never block the next flow indefinitely on a wedged page.
|
|
27
|
+
* **journey:** flow-timeout step renamed from `visit-home` to `flow-timeout` so the summary shows honestly which step the deadline aborted.
|
|
28
|
+
* **journey:** isReachedCheckout regex accepts 13 checkout-flow URL markers (VTEX `/checkout`, Shopify `/checkouts`, Wake `/pedido`, Magento `/onepage`, Nuvemshop `/finalizar`, custom `/secure`, `/pagamento`, etc.) — no longer too strict to VTEX-only patterns.
|
|
29
|
+
* **journey:** `validateCartContainsTitle` scope-qualifies the `[data-product-name]` selector to cart/drawer/checkout/minicart context — prevents false positives where the PDP `<h1>` matched the cart-context selector.
|
|
30
|
+
* **journey:** `validateCartContainsTitle` retries after 2s on empty observation — catches in-flight cart-items XHR finishing slightly late.
|
|
31
|
+
* **journey:** `waitForCartHydration` uses Promise.race (not Promise.all) so the faster of orderForm-XHR / cart-item-selector signals wins, avoiding 8s stalls when one probe never matches.
|
|
32
|
+
* **llm:** `tryRepairJson` recovers from truncated/fenced tool-call arguments returned by some OpenRouter-backed models.
|
|
33
|
+
* **llm:** recovery prompt accepts `a[href*=checkout]` when qualified by text or scope (e.g. `:has-text('Finalizar')`, `[role=dialog] …`). Previous version was too strict and the LLM returned null even when the right element was findable.
|
|
34
|
+
|
|
35
|
+
## [0.3.0](https://github.com/decocms/parity/compare/v0.2.0...v0.3.0) (2026-05-26)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
* **journey:** retry `go-checkout` via LLM recovery when the default selector clicks the wrong element and the URL never reaches `/checkout` ([5826d30](https://github.com/decocms/parity/commit/5826d309c6a910f8cf8017667cdcdcebd65f65d9))
|
|
41
|
+
* **journey:** LLM recovery on `cep-pdp` + `cep-cart` when defaults miss the CEP input ([624b0f5](https://github.com/decocms/parity/commit/624b0f5acbe1c50fce2bea69faa6906e29d36e64))
|
|
42
|
+
* **journey:** per-flow hard deadline so a single hung flow can't freeze the whole crawl ([e2d4a68](https://github.com/decocms/parity/commit/e2d4a681a5d4f0a9627251eceb1e733d12bdc68d))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
* **journey:** abort in-flight Playwright ops when the deadline fires, instead of letting them mutate the next flow's shared BrowserContext ([a21aa78](https://github.com/decocms/parity/commit/a21aa78a4431e7a2cff9aac26ac34c4ca2fa768c))
|
|
48
|
+
* **journey:** seal the timeout FlowCapture synchronously so Promise.race can't pick up the inner rejection caused by closing pages ([d2a0f1e](https://github.com/decocms/parity/commit/d2a0f1e7a08bf95a28a951bace322c5baf062bea))
|
|
49
|
+
* **journey:** await timeout cleanup before runFlow returns so the next flow on the same context isn't racing in-flight close()s ([a2fd6c2](https://github.com/decocms/parity/commit/a2fd6c2e7e7a9eb493d2cb75d08f3d5cc7cc3f91))
|
|
50
|
+
|
|
51
|
+
## [0.2.0](https://github.com/decocms/parity/compare/v0.1.1...v0.2.0) (2026-05-26)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
* **css-trace:** inspect CSS rules affecting a DOM element ([1e323fa](https://github.com/decocms/parity/commit/1e323fa17564045d5f55bfbc5cbf2fcdb0112877))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
### Fixed
|
|
60
|
+
|
|
61
|
+
* **capture:** hard outer deadline so capturePage cannot exceed budget+10s ([966b9a5](https://github.com/decocms/parity/commit/966b9a59851536262b88510da6175a215c90d0b2))
|
|
62
|
+
* **capture:** hard outer deadline so capturePage cannot exceed budget+10s ([ea61116](https://github.com/decocms/parity/commit/ea61116c81fb1ec86e86ddb24216214380a81c81))
|
|
63
|
+
* **lint:** replace 3 template literals without interpolation with strings ([c60f19c](https://github.com/decocms/parity/commit/c60f19c4a56c8441ead829fca0998ee36a671872))
|
|
64
|
+
|
|
8
65
|
## [Unreleased]
|
|
9
66
|
|
|
10
67
|
## [0.1.1] — 2026-05-22
|
package/dist/cli.js
CHANGED
|
@@ -99,7 +99,15 @@ var StepCapture = z.object({
|
|
|
99
99
|
actionDescription: z.string().optional(),
|
|
100
100
|
beforeUrl: z.string().optional(),
|
|
101
101
|
screenshotBeforePath: z.string().optional(),
|
|
102
|
-
tracePath: z.string().optional()
|
|
102
|
+
tracePath: z.string().optional(),
|
|
103
|
+
cartValidation: z.object({
|
|
104
|
+
expectedTitle: z.string(),
|
|
105
|
+
found: z.boolean(),
|
|
106
|
+
method: z.enum(["selector", "llm", "none"]).optional(),
|
|
107
|
+
observedTitles: z.array(z.string()).optional(),
|
|
108
|
+
reason: z.string().optional()
|
|
109
|
+
}).optional(),
|
|
110
|
+
cartOpenMethod: z.enum(["click", "click-navigate", "hover", "already-open", "failed"]).optional()
|
|
103
111
|
});
|
|
104
112
|
var FlowCapture = z.object({
|
|
105
113
|
flow: FlowName,
|
|
@@ -931,6 +939,15 @@ async function stopTracing(ctx, path) {
|
|
|
931
939
|
}
|
|
932
940
|
|
|
933
941
|
// src/engine/collect.ts
|
|
942
|
+
var DEBUG_PARITY = process.env.DEBUG_PARITY === "1" || process.env.DEBUG_PARITY === "true";
|
|
943
|
+
var DEBUG_START = Date.now();
|
|
944
|
+
function dlog(side, viewport, msg) {
|
|
945
|
+
if (!DEBUG_PARITY)
|
|
946
|
+
return;
|
|
947
|
+
const elapsed = ((Date.now() - DEBUG_START) / 1000).toFixed(1);
|
|
948
|
+
process.stderr.write(`[+${elapsed}s ${viewport}/${side}] ${msg}
|
|
949
|
+
`);
|
|
950
|
+
}
|
|
934
951
|
var VITALS_INIT_SCRIPT = `
|
|
935
952
|
(function() {
|
|
936
953
|
if (window.__parity_vitals_installed) return;
|
|
@@ -1141,10 +1158,12 @@ async function capturePage(page, opts) {
|
|
|
1141
1158
|
let html = "";
|
|
1142
1159
|
const inner = async () => {
|
|
1143
1160
|
try {
|
|
1161
|
+
dlog(opts.side, opts.viewport, ` capturePage: goto(${opts.url}) start`);
|
|
1144
1162
|
response = await page.goto(opts.url, {
|
|
1145
1163
|
waitUntil: "domcontentloaded",
|
|
1146
1164
|
timeout: Math.min(opts.timeoutMs ?? 30000, remaining())
|
|
1147
1165
|
});
|
|
1166
|
+
dlog(opts.side, opts.viewport, ` capturePage: goto done status=${response?.status() ?? "?"} (remaining=${remaining()}ms)`);
|
|
1148
1167
|
finalUrl = page.url();
|
|
1149
1168
|
if (response) {
|
|
1150
1169
|
const headers = response.headers();
|
|
@@ -1156,20 +1175,25 @@ async function capturePage(page, opts) {
|
|
|
1156
1175
|
});
|
|
1157
1176
|
await page.waitForTimeout(Math.min(opts.settleMs ?? 1200, remaining()));
|
|
1158
1177
|
} else {
|
|
1178
|
+
dlog(opts.side, opts.viewport, ` capturePage: waitForLoadState('load') (cap=${Math.min(12000, remaining())}ms)`);
|
|
1159
1179
|
await page.waitForLoadState("load", { timeout: Math.min(12000, remaining()) }).catch(() => {
|
|
1160
1180
|
return;
|
|
1161
1181
|
});
|
|
1182
|
+
dlog(opts.side, opts.viewport, ` capturePage: waitForLoadState('networkidle') (cap=${Math.min(6000, remaining())}ms)`);
|
|
1162
1183
|
await page.waitForLoadState("networkidle", { timeout: Math.min(6000, remaining()) }).catch(() => {
|
|
1163
1184
|
return;
|
|
1164
1185
|
});
|
|
1186
|
+
dlog(opts.side, opts.viewport, ` capturePage: settle (cap=${Math.min(opts.settleMs ?? 2000, remaining())}ms)`);
|
|
1165
1187
|
await page.waitForTimeout(Math.min(opts.settleMs ?? 2000, remaining()));
|
|
1166
1188
|
if (opts.scrollToLoad !== false && remaining() > 3000) {
|
|
1189
|
+
dlog(opts.side, opts.viewport, ` capturePage: scrollFullPage start (remaining=${remaining()}ms)`);
|
|
1167
1190
|
await Promise.race([
|
|
1168
1191
|
scrollFullPage(page).catch(() => {
|
|
1169
1192
|
return;
|
|
1170
1193
|
}),
|
|
1171
1194
|
new Promise((resolve) => setTimeout(resolve, Math.min(1e4, remaining())))
|
|
1172
1195
|
]);
|
|
1196
|
+
dlog(opts.side, opts.viewport, ` capturePage: scrollFullPage done (remaining=${remaining()}ms)`);
|
|
1173
1197
|
await page.waitForTimeout(Math.min(600, remaining()));
|
|
1174
1198
|
}
|
|
1175
1199
|
}
|
|
@@ -1179,23 +1203,29 @@ async function capturePage(page, opts) {
|
|
|
1179
1203
|
text: `[navigation-error] ${err.message}`
|
|
1180
1204
|
});
|
|
1181
1205
|
}
|
|
1206
|
+
dlog(opts.side, opts.viewport, ` capturePage: vitals evaluate (cap=${Math.min(5000, remaining())}ms)`);
|
|
1182
1207
|
vitals = await Promise.race([
|
|
1183
1208
|
page.evaluate(() => window.__parity_vitals).catch(() => null),
|
|
1184
1209
|
new Promise((resolve) => setTimeout(() => resolve(null), Math.min(5000, remaining())))
|
|
1185
1210
|
]) ?? null;
|
|
1186
1211
|
if (!opts.skipScreenshot) {
|
|
1212
|
+
dlog(opts.side, opts.viewport, ` capturePage: screenshot start (cap=${Math.min(15000, remaining())}ms)`);
|
|
1187
1213
|
await Promise.race([
|
|
1188
1214
|
page.screenshot({ path: opts.screenshotPath, fullPage: true, animations: "disabled" }).catch(() => {
|
|
1189
1215
|
return;
|
|
1190
1216
|
}),
|
|
1191
1217
|
new Promise((resolve) => setTimeout(resolve, Math.min(15000, remaining())))
|
|
1192
1218
|
]);
|
|
1219
|
+
dlog(opts.side, opts.viewport, " capturePage: screenshot done");
|
|
1193
1220
|
}
|
|
1221
|
+
dlog(opts.side, opts.viewport, ` capturePage: page.content() (cap=${Math.min(5000, remaining())}ms)`);
|
|
1194
1222
|
html = await Promise.race([
|
|
1195
1223
|
page.content().catch(() => ""),
|
|
1196
1224
|
new Promise((resolve) => setTimeout(() => resolve(""), Math.min(5000, remaining())))
|
|
1197
1225
|
]);
|
|
1226
|
+
dlog(opts.side, opts.viewport, ` capturePage: flushCollectors (cap=${Math.min(3000, remaining())}ms)`);
|
|
1198
1227
|
await flushCollectors(state, Math.min(3000, remaining()));
|
|
1228
|
+
dlog(opts.side, opts.viewport, ` capturePage: inner done total=${Date.now() - start}ms`);
|
|
1199
1229
|
return buildPartial();
|
|
1200
1230
|
};
|
|
1201
1231
|
const SAFETY_MARGIN_MS = 1e4;
|
|
@@ -4491,10 +4521,17 @@ async function callOpenRouterTool(params) {
|
|
|
4491
4521
|
const json = await response.json();
|
|
4492
4522
|
const toolCall = json.choices?.[0]?.message?.tool_calls?.[0];
|
|
4493
4523
|
if (toolCall?.function?.name === params.tool.name) {
|
|
4524
|
+
const raw = toolCall.function.arguments ?? "";
|
|
4494
4525
|
try {
|
|
4495
|
-
return JSON.parse(
|
|
4496
|
-
} catch
|
|
4497
|
-
|
|
4526
|
+
return JSON.parse(raw);
|
|
4527
|
+
} catch {
|
|
4528
|
+
const repaired = tryRepairJson(raw);
|
|
4529
|
+
if (repaired) {
|
|
4530
|
+
try {
|
|
4531
|
+
return JSON.parse(repaired);
|
|
4532
|
+
} catch {}
|
|
4533
|
+
}
|
|
4534
|
+
console.error(`[llm-openrouter] failed to parse tool arguments (raw len=${raw.length}, head=${JSON.stringify(raw.slice(0, 80))})`);
|
|
4498
4535
|
return null;
|
|
4499
4536
|
}
|
|
4500
4537
|
}
|
|
@@ -4511,6 +4548,56 @@ async function callOpenRouterTool(params) {
|
|
|
4511
4548
|
}
|
|
4512
4549
|
return null;
|
|
4513
4550
|
}
|
|
4551
|
+
function tryRepairJson(raw) {
|
|
4552
|
+
let s = raw.trim();
|
|
4553
|
+
if (!s)
|
|
4554
|
+
return null;
|
|
4555
|
+
const fence = s.match(/^```(?:json)?\s*([\s\S]*?)\s*```\s*$/);
|
|
4556
|
+
if (fence?.[1])
|
|
4557
|
+
s = fence[1].trim();
|
|
4558
|
+
if (!s.startsWith("{") && !s.startsWith("["))
|
|
4559
|
+
return null;
|
|
4560
|
+
let depthObj = 0;
|
|
4561
|
+
let depthArr = 0;
|
|
4562
|
+
let inString = false;
|
|
4563
|
+
let isEscaped = false;
|
|
4564
|
+
for (let i = 0;i < s.length; i++) {
|
|
4565
|
+
const ch = s[i];
|
|
4566
|
+
if (isEscaped) {
|
|
4567
|
+
isEscaped = false;
|
|
4568
|
+
continue;
|
|
4569
|
+
}
|
|
4570
|
+
if (ch === "\\") {
|
|
4571
|
+
isEscaped = true;
|
|
4572
|
+
continue;
|
|
4573
|
+
}
|
|
4574
|
+
if (ch === '"') {
|
|
4575
|
+
inString = !inString;
|
|
4576
|
+
continue;
|
|
4577
|
+
}
|
|
4578
|
+
if (inString)
|
|
4579
|
+
continue;
|
|
4580
|
+
if (ch === "{")
|
|
4581
|
+
depthObj++;
|
|
4582
|
+
else if (ch === "}")
|
|
4583
|
+
depthObj--;
|
|
4584
|
+
else if (ch === "[")
|
|
4585
|
+
depthArr++;
|
|
4586
|
+
else if (ch === "]")
|
|
4587
|
+
depthArr--;
|
|
4588
|
+
}
|
|
4589
|
+
if (inString)
|
|
4590
|
+
s += '"';
|
|
4591
|
+
while (depthArr > 0) {
|
|
4592
|
+
s += "]";
|
|
4593
|
+
depthArr--;
|
|
4594
|
+
}
|
|
4595
|
+
while (depthObj > 0) {
|
|
4596
|
+
s += "}";
|
|
4597
|
+
depthObj--;
|
|
4598
|
+
}
|
|
4599
|
+
return s;
|
|
4600
|
+
}
|
|
4514
4601
|
async function callMessage(params) {
|
|
4515
4602
|
const provider = getProvider();
|
|
4516
4603
|
if (provider === "anthropic")
|
|
@@ -4794,15 +4881,55 @@ function compactHtmlForRecovery(html, maxChars = 12000) {
|
|
|
4794
4881
|
return html.slice(0, maxChars);
|
|
4795
4882
|
}
|
|
4796
4883
|
}
|
|
4884
|
+
var RECOVERY_SYSTEM_PROMPT = `
|
|
4885
|
+
Você ajuda a recuperar passos de teste E2E (Playwright) que falharam contra sites de e-commerce.
|
|
4886
|
+
|
|
4887
|
+
Receba o nome do step, a ação desejada e o HTML compactado da página ATUAL. Sugira UM seletor + ação ('click' | 'fill' | 'press') que provavelmente funciona NESTE HTML específico.
|
|
4888
|
+
|
|
4889
|
+
REGRAS CRÍTICAS:
|
|
4890
|
+
|
|
4891
|
+
1. **Baseie no HTML fornecido.** Encontre o elemento literal no markup. Confirme mentalmente que o seletor que você vai retornar casa um elemento VISÍVEL no HTML compactado.
|
|
4892
|
+
|
|
4893
|
+
2. **Se o step é 'go-checkout' ou 'go-checkout-retry':**
|
|
4894
|
+
- O alvo é um BOTÃO/LINK CTA com texto visível contendo "Finalizar", "Finalizar compra", "Continuar", "Continuar para pagamento", "Ir para o checkout", "Checkout", "Concluir", "Pagar", "Ir para o pagamento", "Avançar", "Próxima etapa".
|
|
4895
|
+
- **NUNCA** sugira o ícone genérico de carrinho do HEADER (texto curto "0", "1", badge counter, ou aria-label "Carrinho" sozinho). Esse é o gatilho que ABRE o drawer/cart-page, não o que finaliza compra.
|
|
4896
|
+
- **Selectors com href de checkout (\`a[href*="checkout"]\`, \`a[href="/checkout/#/cart?v=1"]\`) PODEM funcionar** mas SOMENTE quando qualificados — ou seja, garanta uma das duas:
|
|
4897
|
+
a. Qualificação por **texto**: \`a[href*="checkout"]:has-text('Finalizar')\`, \`a[href*="checkout"]:has-text('Continuar')\`
|
|
4898
|
+
b. Qualificação por **scope**: \`[role='dialog'] a[href*="checkout"]\`, \`[class*='minicart' i] a[href*="checkout"]\`, \`.cart-drawer a[href*="checkout"]\`
|
|
4899
|
+
Selectors GENÉRICOS sem qualificador (apenas \`a[href*="checkout"]\` sozinho) casam o ícone do header e devem ser EVITADOS.
|
|
4900
|
+
- Se NENHUM elemento com texto/scope plausível existe no HTML, retorne selector="" para sinalizar incerteza honestamente.
|
|
4901
|
+
|
|
4902
|
+
3. **Se o step é 'shipping-calc-cart' ou 'shipping-calc-pdp':**
|
|
4903
|
+
- O alvo é um \`<input>\` (não button/link) visível com label/placeholder mencionando "CEP", "Frete", "Calcular", "Entrega", "Zip".
|
|
4904
|
+
- Prefira inputs cujo container/pai está visível (não escondido em accordion fechado).
|
|
4905
|
+
|
|
4906
|
+
4. **Seletores preferidos** (nesta ordem):
|
|
4907
|
+
- Text-based Playwright: \`button:has-text('Finalizar')\`, \`a:has-text('Continuar para pagamento')\`
|
|
4908
|
+
- data-attributes específicos: \`[data-checkout-button]\`, \`[data-cart-finalize]\`, \`[data-fs-cart-button]\`
|
|
4909
|
+
- ARIA: \`[aria-label*='finalizar' i]\`, \`[role='button'][aria-label*='checkout' i]\`
|
|
4910
|
+
- Classes específicas de plataforma: \`.vtex-minicart-2-x-checkoutButton\`, \`.fs-cart__checkout\`
|
|
4911
|
+
- Por último, escopo hierárquico curto: \`[role='dialog'] button.cta\`, \`.minicart-drawer button[type='submit']\`
|
|
4912
|
+
|
|
4913
|
+
5. **EVITE:**
|
|
4914
|
+
- IDs hash-gerados (\`#r-abc123\`)
|
|
4915
|
+
- Tailwind utility classes encadeadas (\`.flex.items-center.bg-blue\`)
|
|
4916
|
+
- Seletores genéricos sem qualificador (\`a[href*=...]\`, \`button[type=submit]\` sozinho)
|
|
4917
|
+
- Seletores que casam o ELEMENTO ERRADO em outra parte do DOM (ex: link de "checkout your reviews" no footer)
|
|
4918
|
+
- Seletores muito profundos (>4 níveis hierárquicos)
|
|
4919
|
+
|
|
4920
|
+
6. **Quando incerto, retorne selector="" (string vazia).** É preferível que o flow declare a falha honestamente a chutar um seletor errado que apenas clica em coisa aleatória.
|
|
4921
|
+
|
|
4922
|
+
Responda SEMPRE via tool_use suggest_recovery.
|
|
4923
|
+
`.trim();
|
|
4797
4924
|
async function suggestRecovery(input) {
|
|
4798
4925
|
const compacted = compactHtmlForRecovery(input.html);
|
|
4799
4926
|
const inp = await callTool({
|
|
4800
|
-
systemPrompt:
|
|
4927
|
+
systemPrompt: RECOVERY_SYSTEM_PROMPT,
|
|
4801
4928
|
userText: `Step: ${input.stepName}
|
|
4802
4929
|
Ação desejada: ${input.intendedAction}
|
|
4803
|
-
${input.alreadyTried?.length ? `Já tentei: ${input.alreadyTried.join(", ")}
|
|
4930
|
+
${input.alreadyTried?.length ? `Já tentei (não repita estes): ${input.alreadyTried.join(", ")}
|
|
4804
4931
|
` : ""}
|
|
4805
|
-
HTML compactado:
|
|
4932
|
+
HTML compactado da página NESTE momento:
|
|
4806
4933
|
\`\`\`html
|
|
4807
4934
|
${compacted}
|
|
4808
4935
|
\`\`\``,
|
|
@@ -4813,9 +4940,9 @@ ${compacted}
|
|
|
4813
4940
|
inputSchema: RECOVER_STEP_INPUT_SCHEMA
|
|
4814
4941
|
}
|
|
4815
4942
|
});
|
|
4816
|
-
if (inp?.selector && inp.action) {
|
|
4943
|
+
if (inp?.selector?.trim() && inp.action) {
|
|
4817
4944
|
return {
|
|
4818
|
-
selector: inp.selector,
|
|
4945
|
+
selector: inp.selector.trim(),
|
|
4819
4946
|
action: inp.action,
|
|
4820
4947
|
value: inp.value,
|
|
4821
4948
|
reasoning: inp.reasoning
|
|
@@ -5056,22 +5183,79 @@ function isResolutionContext(v) {
|
|
|
5056
5183
|
|
|
5057
5184
|
// src/engine/flows.ts
|
|
5058
5185
|
var PURCHASE_JOURNEY_TOTAL_STEPS = 8;
|
|
5186
|
+
var DEBUG_PARITY2 = process.env.DEBUG_PARITY === "1" || process.env.DEBUG_PARITY === "true";
|
|
5187
|
+
var DEBUG_START2 = Date.now();
|
|
5188
|
+
function dlog2(ctx, msg) {
|
|
5189
|
+
if (!DEBUG_PARITY2)
|
|
5190
|
+
return;
|
|
5191
|
+
const elapsed = ((Date.now() - DEBUG_START2) / 1000).toFixed(1);
|
|
5192
|
+
process.stderr.write(`[+${elapsed}s ${ctx.viewport}/${ctx.side}] ${msg}
|
|
5193
|
+
`);
|
|
5194
|
+
}
|
|
5059
5195
|
function selFor(ctx, key) {
|
|
5060
5196
|
return selectorsFor(key, { rc: ctx.rc, learned: ctx.learned, platform: ctx.platform });
|
|
5061
5197
|
}
|
|
5198
|
+
var FLOW_DEADLINE_MS = {
|
|
5199
|
+
homepage: 90000,
|
|
5200
|
+
plp: 180000,
|
|
5201
|
+
pdp: 240000,
|
|
5202
|
+
"purchase-journey": 360000
|
|
5203
|
+
};
|
|
5062
5204
|
async function runFlow(flow, ctx) {
|
|
5063
5205
|
const start = Date.now();
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
5206
|
+
const deadlineMs = FLOW_DEADLINE_MS[flow];
|
|
5207
|
+
const inner = async () => {
|
|
5208
|
+
switch (flow) {
|
|
5209
|
+
case "homepage":
|
|
5210
|
+
return finalize(flow, ctx, await flowHomepage(ctx), [], start);
|
|
5211
|
+
case "plp":
|
|
5212
|
+
return finalize(flow, ctx, await flowPlp(ctx), [], start);
|
|
5213
|
+
case "pdp":
|
|
5214
|
+
return finalize(flow, ctx, await flowPdp(ctx), [], start);
|
|
5215
|
+
case "purchase-journey": {
|
|
5216
|
+
const { pages, steps } = await flowPurchaseJourney(ctx);
|
|
5217
|
+
return finalize(flow, ctx, pages, steps, start);
|
|
5218
|
+
}
|
|
5074
5219
|
}
|
|
5220
|
+
};
|
|
5221
|
+
const innerPromise = inner();
|
|
5222
|
+
innerPromise.catch(() => {
|
|
5223
|
+
return;
|
|
5224
|
+
});
|
|
5225
|
+
let timer;
|
|
5226
|
+
let cleanup = Promise.resolve();
|
|
5227
|
+
const timeoutPromise = new Promise((resolve) => {
|
|
5228
|
+
timer = setTimeout(() => {
|
|
5229
|
+
const pages = ctx.ctx.pages();
|
|
5230
|
+
resolve(finalize(flow, ctx, [], [
|
|
5231
|
+
{
|
|
5232
|
+
step: 0,
|
|
5233
|
+
name: "flow-timeout",
|
|
5234
|
+
side: ctx.side,
|
|
5235
|
+
viewport: ctx.viewport,
|
|
5236
|
+
status: "failed",
|
|
5237
|
+
durationMs: deadlineMs,
|
|
5238
|
+
screenshotPath: "",
|
|
5239
|
+
actionDescription: `[flow-timeout] flow "${flow}" excedeu ${deadlineMs}ms — captura abortada pela safety net externa, ${pages.length} page(s) fechada(s) para liberar o contexto. Step interno provavelmente travou em uma operação Playwright que não respeitou seu timeout declarado.`
|
|
5240
|
+
}
|
|
5241
|
+
], start));
|
|
5242
|
+
const CLOSE_CAP_MS = 5000;
|
|
5243
|
+
const cappedClose = (p) => Promise.race([
|
|
5244
|
+
p.close().catch(() => {
|
|
5245
|
+
return;
|
|
5246
|
+
}),
|
|
5247
|
+
new Promise((resolve2) => setTimeout(resolve2, CLOSE_CAP_MS))
|
|
5248
|
+
]);
|
|
5249
|
+
cleanup = Promise.allSettled(pages.map(cappedClose));
|
|
5250
|
+
}, deadlineMs);
|
|
5251
|
+
});
|
|
5252
|
+
try {
|
|
5253
|
+
const result = await Promise.race([innerPromise, timeoutPromise]);
|
|
5254
|
+
await cleanup;
|
|
5255
|
+
return result;
|
|
5256
|
+
} finally {
|
|
5257
|
+
if (timer !== undefined)
|
|
5258
|
+
clearTimeout(timer);
|
|
5075
5259
|
}
|
|
5076
5260
|
}
|
|
5077
5261
|
function finalize(flow, ctx, pages, steps, start) {
|
|
@@ -5178,12 +5362,15 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5178
5362
|
};
|
|
5179
5363
|
try {
|
|
5180
5364
|
reportStart(1, "visit-home");
|
|
5365
|
+
dlog2(ctx, `step 1 visit-home: capturePage(${ctx.baseUrl}) start (scrollToLoad=false)`);
|
|
5181
5366
|
const homeCap = await capturePage(page, {
|
|
5182
5367
|
url: ctx.baseUrl,
|
|
5183
5368
|
side: ctx.side,
|
|
5184
5369
|
viewport: ctx.viewport,
|
|
5185
|
-
screenshotPath: screenshotPath(ctx, "pj-1-home")
|
|
5370
|
+
screenshotPath: screenshotPath(ctx, "pj-1-home"),
|
|
5371
|
+
scrollToLoad: false
|
|
5186
5372
|
});
|
|
5373
|
+
dlog2(ctx, `step 1 visit-home: capturePage done status=${homeCap.status}`);
|
|
5187
5374
|
pages.push(homeCap);
|
|
5188
5375
|
const step1Status = homeCap.status >= 200 && homeCap.status < 400 ? "ok" : "failed";
|
|
5189
5376
|
steps.push({
|
|
@@ -5202,19 +5389,24 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5202
5389
|
return { pages, steps };
|
|
5203
5390
|
}
|
|
5204
5391
|
reportStart(2, "navigate-plp");
|
|
5392
|
+
dlog2(ctx, `step 2 navigate-plp: findCategoryUrl start (hint=${ctx.rc.plpUrlHint ?? "—"})`);
|
|
5205
5393
|
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"}`);
|
|
5206
5395
|
if (!plpHit) {
|
|
5207
5396
|
steps.push(makeSkipStep(2, "navigate-plp", ctx, "no category link found"));
|
|
5208
5397
|
reportEnd(2, "navigate-plp", "skipped", 0, "no category link found");
|
|
5209
5398
|
return { pages, steps };
|
|
5210
5399
|
}
|
|
5211
5400
|
const t2 = Date.now();
|
|
5401
|
+
dlog2(ctx, `step 2 navigate-plp: capturePage(${plpHit.url}) start (scrollToLoad=false)`);
|
|
5212
5402
|
const plpCap = await capturePage(page, {
|
|
5213
5403
|
url: plpHit.url,
|
|
5214
5404
|
side: ctx.side,
|
|
5215
5405
|
viewport: ctx.viewport,
|
|
5216
|
-
screenshotPath: screenshotPath(ctx, "pj-2-plp")
|
|
5406
|
+
screenshotPath: screenshotPath(ctx, "pj-2-plp"),
|
|
5407
|
+
scrollToLoad: false
|
|
5217
5408
|
});
|
|
5409
|
+
dlog2(ctx, `step 2 navigate-plp: capturePage done status=${plpCap.status}`);
|
|
5218
5410
|
pages.push(plpCap);
|
|
5219
5411
|
const step2Status = plpCap.status >= 200 && plpCap.status < 400 ? "ok" : "failed";
|
|
5220
5412
|
steps.push({
|
|
@@ -5244,7 +5436,8 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5244
5436
|
url: pdpHit.url,
|
|
5245
5437
|
side: ctx.side,
|
|
5246
5438
|
viewport: ctx.viewport,
|
|
5247
|
-
screenshotPath: screenshotPath(ctx, "pj-3-pdp")
|
|
5439
|
+
screenshotPath: screenshotPath(ctx, "pj-3-pdp"),
|
|
5440
|
+
scrollToLoad: false
|
|
5248
5441
|
});
|
|
5249
5442
|
pages.push(pdpCap);
|
|
5250
5443
|
const step3Status = pdpCap.status >= 200 && pdpCap.status < 400 ? "ok" : "failed";
|
|
@@ -5263,8 +5456,25 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5263
5456
|
reportEnd(3, "enter-pdp", step3Status, Date.now() - t3);
|
|
5264
5457
|
steps[steps.length - 1].actionDescription = `Abriu PDP \`${pdpHit.url}\` (via \`${pdpHit.selector}\`)`;
|
|
5265
5458
|
steps[steps.length - 1].beforeUrl = plpHit.url;
|
|
5459
|
+
const expectedProductTitle = await extractProductTitle(page);
|
|
5460
|
+
dlog2(ctx, `step 3 enter-pdp: extracted product title → ${expectedProductTitle ? `"${expectedProductTitle.slice(0, 60)}"` : "null"}`);
|
|
5461
|
+
if (expectedProductTitle) {
|
|
5462
|
+
steps[steps.length - 1].detail = {
|
|
5463
|
+
...steps[steps.length - 1].detail ?? {},
|
|
5464
|
+
productTitle: expectedProductTitle
|
|
5465
|
+
};
|
|
5466
|
+
}
|
|
5266
5467
|
reportStart(4, "shipping-calc-pdp");
|
|
5267
|
-
|
|
5468
|
+
let cepInputPdp = await firstVisible(page, selFor(ctx, "cepInputPdp"));
|
|
5469
|
+
let cepPdpRecovered = false;
|
|
5470
|
+
if (!cepInputPdp && recoveryBudget > 0) {
|
|
5471
|
+
const recovery = await attemptRecovery(page, ctx, "shipping-calc-pdp", "Achar o input de CEP / código postal nesta PDP (deve ser um input visível com label/placeholder relacionado a frete, entrega ou CEP)", selFor(ctx, "cepInputPdp"));
|
|
5472
|
+
if (recovery) {
|
|
5473
|
+
cepInputPdp = recovery.selector;
|
|
5474
|
+
cepPdpRecovered = true;
|
|
5475
|
+
recoveryBudget--;
|
|
5476
|
+
}
|
|
5477
|
+
}
|
|
5268
5478
|
if (cepInputPdp) {
|
|
5269
5479
|
const t4 = Date.now();
|
|
5270
5480
|
const beforeUrl4 = page.url();
|
|
@@ -5289,14 +5499,15 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5289
5499
|
screenshotPath: sp,
|
|
5290
5500
|
screenshotBeforePath: spBefore4,
|
|
5291
5501
|
beforeUrl: beforeUrl4,
|
|
5292
|
-
actionDescription: `Preencheu CEP '${ctx.rc.cep}' no input \`${cepInputPdp}\` e disparou cálculo de frete`,
|
|
5502
|
+
actionDescription: `Preencheu CEP '${ctx.rc.cep}' no input \`${cepInputPdp}\` e disparou cálculo de frete${cepPdpRecovered ? " (selector via LLM recovery)" : ""}`,
|
|
5293
5503
|
detail: { cepUsed: ctx.rc.cep },
|
|
5294
5504
|
selectorKey: "cepInputPdp",
|
|
5295
|
-
usedSelector: cepInputPdp
|
|
5505
|
+
usedSelector: cepInputPdp,
|
|
5506
|
+
recoveredByLlm: cepPdpRecovered || undefined
|
|
5296
5507
|
});
|
|
5297
5508
|
reportEnd(4, "shipping-calc-pdp", step4Status, Date.now() - t4);
|
|
5298
5509
|
} else {
|
|
5299
|
-
steps.push(makeSkipStep(4, "shipping-calc-pdp", ctx, "no CEP input on PDP"));
|
|
5510
|
+
steps.push(makeSkipStep(4, "shipping-calc-pdp", ctx, "no CEP input on PDP (recovery exhausted)"));
|
|
5300
5511
|
reportEnd(4, "shipping-calc-pdp", "skipped", 0, "no CEP input on PDP");
|
|
5301
5512
|
}
|
|
5302
5513
|
reportStart(5, "add-to-cart");
|
|
@@ -5364,37 +5575,79 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5364
5575
|
recoveryBudget--;
|
|
5365
5576
|
}
|
|
5366
5577
|
}
|
|
5578
|
+
let cartOpenMethod = "failed";
|
|
5367
5579
|
let miniText = "";
|
|
5368
5580
|
if (miniHit) {
|
|
5369
5581
|
miniText = await miniHit.locator.innerText().catch(() => "");
|
|
5370
|
-
await
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
await page
|
|
5582
|
+
const openResult = await openMinicart(page, miniHit, ctx, expectedProductTitle);
|
|
5583
|
+
cartOpenMethod = openResult.method;
|
|
5584
|
+
} else {
|
|
5585
|
+
const alreadyOpen = await isCartRevealed(page, expectedProductTitle);
|
|
5586
|
+
if (alreadyOpen) {
|
|
5587
|
+
cartOpenMethod = "already-open";
|
|
5588
|
+
dlog2(ctx, `step 6 open-minicart: no trigger, but drawer already visible (matched ${alreadyOpen})`);
|
|
5589
|
+
}
|
|
5590
|
+
}
|
|
5591
|
+
let step6Validation;
|
|
5592
|
+
if (expectedProductTitle && cartOpenMethod !== "failed") {
|
|
5593
|
+
const v = await validateCartContainsTitle(page, expectedProductTitle, ctx);
|
|
5594
|
+
let reasonText;
|
|
5595
|
+
if (!v.found) {
|
|
5596
|
+
if (v.observedTitles.length === 0) {
|
|
5597
|
+
const emptyBanner = await detectEmptyCartBanner(page);
|
|
5598
|
+
reasonText = emptyBanner ? `cart genuinely empty — upstream add-to-cart didn't persist (session/cookie may not have carried over). Banner observed: "${emptyBanner}"` : "no cart line items visible after open (selectors may not match markup)";
|
|
5599
|
+
} else {
|
|
5600
|
+
reasonText = `expected title not found among ${v.observedTitles.length} observed`;
|
|
5601
|
+
}
|
|
5602
|
+
}
|
|
5603
|
+
step6Validation = {
|
|
5604
|
+
expectedTitle: expectedProductTitle,
|
|
5605
|
+
found: v.found,
|
|
5606
|
+
method: v.method,
|
|
5607
|
+
observedTitles: v.observedTitles.slice(0, 8),
|
|
5608
|
+
reason: reasonText
|
|
5609
|
+
};
|
|
5610
|
+
dlog2(ctx, `step 6 open-minicart: cart validation → found=${v.found} (${v.method})${reasonText ? ` — ${reasonText.slice(0, 80)}` : ""}`);
|
|
5611
|
+
} else if (!expectedProductTitle) {
|
|
5612
|
+
dlog2(ctx, "step 6 open-minicart: skipping validation — no PDP title captured");
|
|
5613
|
+
} else {
|
|
5614
|
+
dlog2(ctx, "step 6 open-minicart: skipping validation — cart UI not revealed");
|
|
5374
5615
|
}
|
|
5375
5616
|
const sp6 = screenshotPath(ctx, "pj-6-minicart");
|
|
5376
5617
|
await page.screenshot({ path: sp6, fullPage: false }).catch(() => {
|
|
5377
5618
|
return;
|
|
5378
5619
|
});
|
|
5620
|
+
const step6Status = cartOpenMethod === "failed" ? "failed" : step6Validation && !step6Validation.found ? "failed" : "ok";
|
|
5379
5621
|
steps.push({
|
|
5380
5622
|
step: 6,
|
|
5381
5623
|
name: "open-minicart",
|
|
5382
5624
|
side: ctx.side,
|
|
5383
5625
|
viewport: ctx.viewport,
|
|
5384
|
-
status:
|
|
5626
|
+
status: step6Status,
|
|
5385
5627
|
durationMs: Date.now() - t6,
|
|
5386
5628
|
url: page.url(),
|
|
5387
5629
|
screenshotPath: sp6,
|
|
5388
5630
|
screenshotBeforePath: spBefore6,
|
|
5389
5631
|
beforeUrl: beforeUrl6,
|
|
5390
|
-
|
|
5632
|
+
cartOpenMethod,
|
|
5633
|
+
cartValidation: step6Validation,
|
|
5634
|
+
actionDescription: miniHit ? `Abriu minicart via ${cartOpenMethod}${miniText ? ` em '${miniText.slice(0, 30).trim()}'` : ""} (\`${miniHit.selector}\`)${miniRecovered ? " — selector via recovery LLM" : ""}${step6Validation ? step6Validation.found ? " ✓ produto encontrado no cart" : ` ✗ produto ESPERADO não encontrado (${step6Validation.observedTitles?.length ?? 0} itens observados)` : ""}` : cartOpenMethod === "already-open" ? "Minicart já estava aberto após add-to-cart" : "Minicart não pôde ser aberto",
|
|
5391
5635
|
selectorKey: miniHit ? "minicartTrigger" : undefined,
|
|
5392
5636
|
usedSelector: miniHit?.selector,
|
|
5393
5637
|
recoveredByLlm: miniRecovered || undefined
|
|
5394
5638
|
});
|
|
5395
|
-
reportEnd(6, "open-minicart",
|
|
5639
|
+
reportEnd(6, "open-minicart", step6Status, Date.now() - t6);
|
|
5396
5640
|
reportStart(7, "shipping-calc-cart");
|
|
5397
|
-
|
|
5641
|
+
let cepInputCart = await firstVisible(page, selFor(ctx, "cepInputCart"));
|
|
5642
|
+
let cepCartRecovered = false;
|
|
5643
|
+
if (!cepInputCart && recoveryBudget > 0) {
|
|
5644
|
+
const recovery = await attemptRecovery(page, ctx, "shipping-calc-cart", "Achar o input de CEP / código postal dentro do carrinho ou minicart aberto agora (input visível com label/placeholder de frete, entrega ou CEP)", selFor(ctx, "cepInputCart"));
|
|
5645
|
+
if (recovery) {
|
|
5646
|
+
cepInputCart = recovery.selector;
|
|
5647
|
+
cepCartRecovered = true;
|
|
5648
|
+
recoveryBudget--;
|
|
5649
|
+
}
|
|
5650
|
+
}
|
|
5398
5651
|
if (cepInputCart) {
|
|
5399
5652
|
const t7 = Date.now();
|
|
5400
5653
|
const beforeUrl7 = page.url();
|
|
@@ -5419,21 +5672,83 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5419
5672
|
screenshotPath: sp7,
|
|
5420
5673
|
screenshotBeforePath: spBefore7,
|
|
5421
5674
|
beforeUrl: beforeUrl7,
|
|
5422
|
-
actionDescription: `Preencheu CEP '${ctx.rc.cep}' no carrinho (\`${cepInputCart}\`)`,
|
|
5675
|
+
actionDescription: `Preencheu CEP '${ctx.rc.cep}' no carrinho (\`${cepInputCart}\`)${cepCartRecovered ? " (selector via LLM recovery)" : ""}`,
|
|
5423
5676
|
detail: { cepUsed: ctx.rc.cep },
|
|
5424
5677
|
selectorKey: "cepInputCart",
|
|
5425
|
-
usedSelector: cepInputCart
|
|
5678
|
+
usedSelector: cepInputCart,
|
|
5679
|
+
recoveredByLlm: cepCartRecovered || undefined
|
|
5426
5680
|
});
|
|
5427
5681
|
reportEnd(7, "shipping-calc-cart", step7Status, Date.now() - t7);
|
|
5428
5682
|
} else {
|
|
5429
|
-
steps.push(makeSkipStep(7, "shipping-calc-cart", ctx, "no CEP input in cart"));
|
|
5683
|
+
steps.push(makeSkipStep(7, "shipping-calc-cart", ctx, "no CEP input in cart (recovery exhausted)"));
|
|
5430
5684
|
reportEnd(7, "shipping-calc-cart", "skipped", 0, "no CEP input in cart");
|
|
5431
5685
|
}
|
|
5432
5686
|
reportStart(8, "go-checkout");
|
|
5433
|
-
|
|
5687
|
+
const beforeStep8Url = page.url();
|
|
5688
|
+
const alreadyInCheckoutPage = /\/(checkout|cart|carrinho)(\/|#|$|\?)/i.test(beforeStep8Url);
|
|
5689
|
+
dlog2(ctx, `step 8 go-checkout: beforeUrl=${beforeStep8Url} alreadyInCheckoutPage=${alreadyInCheckoutPage}`);
|
|
5690
|
+
if (!alreadyInCheckoutPage) {
|
|
5691
|
+
const drawerStillOpen = await firstVisible(page, [
|
|
5692
|
+
"[role='dialog']:visible",
|
|
5693
|
+
"[aria-modal='true']:visible",
|
|
5694
|
+
"[data-minicart][aria-hidden='false']",
|
|
5695
|
+
".minicart--open",
|
|
5696
|
+
".minicart-drawer:not([hidden])",
|
|
5697
|
+
"[class*='minicart'][class*='open']",
|
|
5698
|
+
"[class*='cart-drawer'][class*='open']"
|
|
5699
|
+
]);
|
|
5700
|
+
if (!drawerStillOpen) {
|
|
5701
|
+
dlog2(ctx, "step 8 go-checkout: drawer appears closed, re-clicking minicart trigger");
|
|
5702
|
+
const reTrigger = await firstVisibleLocator(page, selFor(ctx, "minicartTrigger"));
|
|
5703
|
+
if (reTrigger) {
|
|
5704
|
+
await reTrigger.locator.click({ timeout: 3000 }).catch(() => {
|
|
5705
|
+
return;
|
|
5706
|
+
});
|
|
5707
|
+
await page.waitForTimeout(1500);
|
|
5708
|
+
dlog2(ctx, `step 8 go-checkout: re-opened drawer via ${reTrigger.selector}`);
|
|
5709
|
+
} else {
|
|
5710
|
+
dlog2(ctx, "step 8 go-checkout: minicartTrigger not found — drawer state unknown");
|
|
5711
|
+
}
|
|
5712
|
+
} else {
|
|
5713
|
+
dlog2(ctx, `step 8 go-checkout: drawer still open (matched ${drawerStillOpen})`);
|
|
5714
|
+
}
|
|
5715
|
+
}
|
|
5716
|
+
const nextStepSelectors = alreadyInCheckoutPage ? [
|
|
5717
|
+
"button:has-text('Continuar para pagamento')",
|
|
5718
|
+
"a:has-text('Continuar para pagamento')",
|
|
5719
|
+
"button:has-text('Ir para o pagamento')",
|
|
5720
|
+
"a:has-text('Ir para o pagamento')",
|
|
5721
|
+
"button:has-text('Continuar')",
|
|
5722
|
+
"a:has-text('Continuar')",
|
|
5723
|
+
"button:has-text('Avançar')",
|
|
5724
|
+
"a:has-text('Avançar')",
|
|
5725
|
+
"button:has-text('Próxima etapa')",
|
|
5726
|
+
"a:has-text('Próxima etapa')",
|
|
5727
|
+
"button:has-text('Confirmar')",
|
|
5728
|
+
"button:has-text('Finalizar')",
|
|
5729
|
+
"a:has-text('Finalizar')",
|
|
5730
|
+
"#cart-to-orderform",
|
|
5731
|
+
"#btn-go-to-payment",
|
|
5732
|
+
".btn-go-to-payment",
|
|
5733
|
+
"a.btn-place-order",
|
|
5734
|
+
"a.orange-btn",
|
|
5735
|
+
".cart-links-bottom a",
|
|
5736
|
+
"button[type='submit'][class*='checkout' i]",
|
|
5737
|
+
"button[type='submit'][class*='continue' i]",
|
|
5738
|
+
"[data-checkout-next]",
|
|
5739
|
+
"[data-fs-cart-checkout-button]"
|
|
5740
|
+
] : [];
|
|
5741
|
+
const baseSelectors = selFor(ctx, "checkoutButton");
|
|
5742
|
+
const checkoutSelectors = [...nextStepSelectors, ...baseSelectors];
|
|
5743
|
+
dlog2(ctx, `step 8 go-checkout: firstVisibleLocator on ${checkoutSelectors.length} selectors${alreadyInCheckoutPage ? ` (${nextStepSelectors.length} next-step prefixed)` : ""}`);
|
|
5744
|
+
let checkoutHit = await firstVisibleLocator(page, checkoutSelectors);
|
|
5745
|
+
dlog2(ctx, `step 8 go-checkout: default match → ${checkoutHit ? `selector=${checkoutHit.selector}` : "null"}`);
|
|
5434
5746
|
let checkoutRecovered = false;
|
|
5435
5747
|
if (!checkoutHit && recoveryBudget > 0) {
|
|
5436
|
-
const
|
|
5748
|
+
const recoveryIntent = alreadyInCheckoutPage ? "Já estou no checkout (URL contém /checkout ou /cart). Achar o botão visível que avança pra próxima etapa do checkout (geralmente 'Continuar para pagamento', 'Continuar', 'Avançar', 'Próxima etapa', 'Confirmar', ou similar). NÃO é o ícone do header — é uma CTA primary na página." : "Clicar no botão 'Finalizar compra' / 'Ir para o checkout' / 'Finalizar' dentro do drawer/popup do carrinho aberto.";
|
|
5749
|
+
dlog2(ctx, `step 8 go-checkout: defaults missed, calling attemptRecovery (budget=${recoveryBudget}, mode=${alreadyInCheckoutPage ? "advance-checkout" : "drawer-finalize"})`);
|
|
5750
|
+
const recovery = await attemptRecovery(page, ctx, "go-checkout", recoveryIntent, checkoutSelectors);
|
|
5751
|
+
dlog2(ctx, `step 8 go-checkout: LLM recovery → ${recovery ? `selector=${recovery.selector}` : "null"}`);
|
|
5437
5752
|
if (recovery) {
|
|
5438
5753
|
checkoutHit = recovery;
|
|
5439
5754
|
checkoutRecovered = true;
|
|
@@ -5441,33 +5756,92 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5441
5756
|
}
|
|
5442
5757
|
}
|
|
5443
5758
|
if (!checkoutHit) {
|
|
5759
|
+
dlog2(ctx, "step 8 go-checkout: no button found, skipping");
|
|
5444
5760
|
steps.push(makeSkipStep(8, "go-checkout", ctx, "no checkout button found (recovery exhausted)"));
|
|
5445
5761
|
reportEnd(8, "go-checkout", "skipped", 0, "no checkout button found");
|
|
5446
5762
|
return { pages, steps };
|
|
5447
5763
|
}
|
|
5448
|
-
const
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
await page.screenshot({ path: spBefore8, fullPage: false }).catch(() => {
|
|
5452
|
-
return;
|
|
5453
|
-
});
|
|
5454
|
-
const checkoutText = await checkoutHit.locator.innerText().catch(() => "");
|
|
5455
|
-
await Promise.all([
|
|
5456
|
-
page.waitForURL(/checkout/i, { timeout: 1e4 }).catch(() => {
|
|
5764
|
+
const tryCheckoutClick = async (hit, attempt2) => {
|
|
5765
|
+
const spBefore = screenshotPath(ctx, `pj-8-checkout-before-${attempt2}`);
|
|
5766
|
+
await page.screenshot({ path: spBefore, fullPage: false }).catch(() => {
|
|
5457
5767
|
return;
|
|
5458
|
-
})
|
|
5459
|
-
|
|
5768
|
+
});
|
|
5769
|
+
const clickedText2 = await hit.locator.innerText().catch(() => "");
|
|
5770
|
+
const urlBefore = page.url();
|
|
5771
|
+
const urlChangePredicate = alreadyInCheckoutPage ? (u) => u.toString() !== urlBefore : /checkout/i;
|
|
5772
|
+
await Promise.all([
|
|
5773
|
+
page.waitForURL(urlChangePredicate, { timeout: 1e4 }).catch(() => {
|
|
5774
|
+
return;
|
|
5775
|
+
}),
|
|
5776
|
+
hit.locator.click({ timeout: 5000 }).catch(() => {
|
|
5777
|
+
return;
|
|
5778
|
+
})
|
|
5779
|
+
]);
|
|
5780
|
+
await page.waitForTimeout(1500);
|
|
5781
|
+
const spAfter = screenshotPath(ctx, `pj-8-checkout-reached-${attempt2}`);
|
|
5782
|
+
await page.screenshot({ path: spAfter, fullPage: false }).catch(() => {
|
|
5460
5783
|
return;
|
|
5461
|
-
})
|
|
5462
|
-
|
|
5463
|
-
|
|
5464
|
-
const
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
5468
|
-
|
|
5469
|
-
|
|
5470
|
-
|
|
5784
|
+
});
|
|
5785
|
+
return { url: page.url(), spBefore, spAfter, clickedText: clickedText2 };
|
|
5786
|
+
};
|
|
5787
|
+
const t8 = Date.now();
|
|
5788
|
+
const beforeUrl8 = page.url();
|
|
5789
|
+
const CHECKOUT_URL_MARKERS = /\/(checkout|checkouts|finalize|finalizar|pagamento|payment|pedido|order|orderform|onepage|secure|seguro)(\/|#|$|\?|-)/i;
|
|
5790
|
+
const isReachedCheckout = (finalUrl) => {
|
|
5791
|
+
if (finalUrl === beforeUrl8)
|
|
5792
|
+
return false;
|
|
5793
|
+
return CHECKOUT_URL_MARKERS.test(finalUrl);
|
|
5794
|
+
};
|
|
5795
|
+
let attempt = 1;
|
|
5796
|
+
dlog2(ctx, `step 8 go-checkout: tryCheckoutClick attempt 1 — selector=${checkoutHit.selector}, beforeUrl=${beforeUrl8}`);
|
|
5797
|
+
let result = await tryCheckoutClick(checkoutHit, attempt);
|
|
5798
|
+
let reachedCheckout = isReachedCheckout(result.url);
|
|
5799
|
+
let usedSelector = checkoutHit.selector;
|
|
5800
|
+
let clickedText = result.clickedText;
|
|
5801
|
+
dlog2(ctx, `step 8 go-checkout: attempt 1 result — finalUrl=${result.url} reached=${reachedCheckout} clicked='${clickedText.slice(0, 40).trim()}'`);
|
|
5802
|
+
if (!reachedCheckout && recoveryBudget > 0) {
|
|
5803
|
+
dlog2(ctx, `step 8 go-checkout: attempt 1 missed target — calling attemptRecovery for retry (budget=${recoveryBudget}, mode=${alreadyInCheckoutPage ? "advance-checkout" : "drawer-finalize"})`);
|
|
5804
|
+
const retryIntent = alreadyInCheckoutPage ? `Já estou no checkout, em ${beforeUrl8}. Cliquei em '${clickedText.slice(0, 40).trim()}' (selector \`${usedSelector}\`), mas a URL não mudou ou foi pra ${result.url}. Achar o botão que avança pra próxima etapa do checkout (Continuar para pagamento, Avançar, Próxima etapa, Confirmar, etc).` : `Cliquei em '${clickedText.slice(0, 40).trim()}' (selector \`${usedSelector}\`), mas a URL ficou em ${result.url} e não foi pra /checkout. Achar o botão que de fato navega pra /checkout neste cart/minicart aberto.`;
|
|
5805
|
+
const retrySuggestion = await attemptRecovery(page, ctx, "go-checkout-retry", retryIntent, [usedSelector, ...checkoutSelectors]);
|
|
5806
|
+
dlog2(ctx, `step 8 go-checkout: retry LLM suggestion → ${retrySuggestion ? `selector=${retrySuggestion.selector}` : "null"}`);
|
|
5807
|
+
if (retrySuggestion) {
|
|
5808
|
+
recoveryBudget--;
|
|
5809
|
+
checkoutRecovered = true;
|
|
5810
|
+
attempt++;
|
|
5811
|
+
dlog2(ctx, `step 8 go-checkout: tryCheckoutClick attempt 2 — selector=${retrySuggestion.selector}`);
|
|
5812
|
+
const retryResult = await tryCheckoutClick(retrySuggestion, attempt);
|
|
5813
|
+
result = retryResult;
|
|
5814
|
+
usedSelector = retrySuggestion.selector;
|
|
5815
|
+
clickedText = retryResult.clickedText;
|
|
5816
|
+
reachedCheckout = isReachedCheckout(retryResult.url);
|
|
5817
|
+
dlog2(ctx, `step 8 go-checkout: attempt 2 result — finalUrl=${retryResult.url} reached=${reachedCheckout} clicked='${retryResult.clickedText.slice(0, 40).trim()}'`);
|
|
5818
|
+
}
|
|
5819
|
+
} else if (!reachedCheckout) {
|
|
5820
|
+
dlog2(ctx, `step 8 go-checkout: not retrying — recoveryBudget=${recoveryBudget}`);
|
|
5821
|
+
}
|
|
5822
|
+
let step8Validation;
|
|
5823
|
+
if (expectedProductTitle && reachedCheckout) {
|
|
5824
|
+
await page.waitForTimeout(1500);
|
|
5825
|
+
const v = await validateCartContainsTitle(page, expectedProductTitle, ctx);
|
|
5826
|
+
let reasonText;
|
|
5827
|
+
if (!v.found) {
|
|
5828
|
+
if (v.observedTitles.length === 0) {
|
|
5829
|
+
const emptyBanner = await detectEmptyCartBanner(page);
|
|
5830
|
+
reasonText = emptyBanner ? `checkout shows empty cart banner — upstream session lost. Banner: "${emptyBanner}"` : "checkout page has no visible line items (selectors may not match markup)";
|
|
5831
|
+
} else {
|
|
5832
|
+
reasonText = `expected title not found among ${v.observedTitles.length} observed on checkout`;
|
|
5833
|
+
}
|
|
5834
|
+
}
|
|
5835
|
+
step8Validation = {
|
|
5836
|
+
expectedTitle: expectedProductTitle,
|
|
5837
|
+
found: v.found,
|
|
5838
|
+
method: v.method,
|
|
5839
|
+
observedTitles: v.observedTitles.slice(0, 8),
|
|
5840
|
+
reason: reasonText
|
|
5841
|
+
};
|
|
5842
|
+
dlog2(ctx, `step 8 go-checkout: checkout validation → found=${v.found} (${v.method})${reasonText ? ` — ${reasonText.slice(0, 80)}` : ""}`);
|
|
5843
|
+
}
|
|
5844
|
+
const step8Status = reachedCheckout ? step8Validation && !step8Validation.found ? "failed" : "ok" : "failed";
|
|
5471
5845
|
steps.push({
|
|
5472
5846
|
step: 8,
|
|
5473
5847
|
name: "go-checkout",
|
|
@@ -5475,13 +5849,14 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5475
5849
|
viewport: ctx.viewport,
|
|
5476
5850
|
status: step8Status,
|
|
5477
5851
|
durationMs: Date.now() - t8,
|
|
5478
|
-
url:
|
|
5479
|
-
screenshotPath:
|
|
5480
|
-
screenshotBeforePath:
|
|
5852
|
+
url: result.url,
|
|
5853
|
+
screenshotPath: result.spAfter,
|
|
5854
|
+
screenshotBeforePath: result.spBefore,
|
|
5481
5855
|
beforeUrl: beforeUrl8,
|
|
5482
|
-
|
|
5856
|
+
cartValidation: step8Validation,
|
|
5857
|
+
actionDescription: `Clicou em${clickedText ? ` '${clickedText.slice(0, 30).trim()}'` : ""} (\`${usedSelector}\`); URL final: ${result.url}${reachedCheckout ? " ✓ atingiu /checkout" : " ✗ não foi pra checkout"}${attempt > 1 ? ` (após ${attempt} tentativas com recovery LLM)` : ""}${step8Validation ? step8Validation.found ? " ✓ produto persiste no checkout" : ` ✗ produto ESPERADO ausente no checkout (${step8Validation.observedTitles?.length ?? 0} itens observados)` : ""}`,
|
|
5483
5858
|
selectorKey: "checkoutButton",
|
|
5484
|
-
usedSelector
|
|
5859
|
+
usedSelector,
|
|
5485
5860
|
recoveredByLlm: checkoutRecovered || undefined
|
|
5486
5861
|
});
|
|
5487
5862
|
reportEnd(8, "go-checkout", step8Status, Date.now() - t8);
|
|
@@ -5504,10 +5879,15 @@ function makeSkipStep(step, name, ctx, note) {
|
|
|
5504
5879
|
}
|
|
5505
5880
|
async function findCategoryUrl(page, ctx) {
|
|
5506
5881
|
const selectors = selFor(ctx, "categoryLink");
|
|
5882
|
+
dlog2(ctx, ` findCategoryUrl: collectCandidateLinks(${selectors.length} selectors) start`);
|
|
5883
|
+
const t0 = Date.now();
|
|
5507
5884
|
const candidates = await collectCandidateLinks(page, selectors, 12);
|
|
5885
|
+
dlog2(ctx, ` findCategoryUrl: collectCandidateLinks done in ${Date.now() - t0}ms → ${candidates.length} candidates`);
|
|
5508
5886
|
if (candidates.length === 0)
|
|
5509
5887
|
return null;
|
|
5888
|
+
dlog2(ctx, " findCategoryUrl: pickCategoryLink LLM call start");
|
|
5510
5889
|
const picked = await pickCategoryLink(candidates.map((c) => ({ text: c.text, href: c.href })));
|
|
5890
|
+
dlog2(ctx, ` findCategoryUrl: pickCategoryLink LLM done → ${picked?.href ?? "null"}`);
|
|
5511
5891
|
if (!picked)
|
|
5512
5892
|
return null;
|
|
5513
5893
|
const original = candidates.find((c) => c.href === picked.href);
|
|
@@ -5556,20 +5936,34 @@ async function firstVisibleLocator(page, selectors) {
|
|
|
5556
5936
|
}
|
|
5557
5937
|
return null;
|
|
5558
5938
|
}
|
|
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
|
+
}
|
|
5559
5946
|
async function collectCandidateLinks(page, selectors, limit = 12) {
|
|
5560
5947
|
const out = [];
|
|
5561
5948
|
const seenHrefs = new Set;
|
|
5949
|
+
const deadline = Date.now() + COLLECT_CANDIDATES_BUDGET_MS;
|
|
5950
|
+
const expired = () => Date.now() >= deadline;
|
|
5562
5951
|
for (const sel of selectors) {
|
|
5563
5952
|
if (out.length >= limit)
|
|
5564
5953
|
break;
|
|
5954
|
+
if (expired())
|
|
5955
|
+
break;
|
|
5565
5956
|
try {
|
|
5566
5957
|
const elements = page.locator(sel);
|
|
5567
|
-
const count = await elements.count();
|
|
5958
|
+
const count = await withCap(elements.count(), 1000, 0);
|
|
5568
5959
|
for (let i = 0;i < count && out.length < limit; i++) {
|
|
5960
|
+
if (expired())
|
|
5961
|
+
break;
|
|
5569
5962
|
const el = elements.nth(i);
|
|
5570
|
-
|
|
5963
|
+
const visible = await withCap(el.isVisible({ timeout: 250 }).catch(() => false), 400, false);
|
|
5964
|
+
if (!visible)
|
|
5571
5965
|
continue;
|
|
5572
|
-
const href = await el.getAttribute("href").catch(() => null);
|
|
5966
|
+
const href = await withCap(el.getAttribute("href").catch(() => null), 400, null);
|
|
5573
5967
|
if (!href)
|
|
5574
5968
|
continue;
|
|
5575
5969
|
let abs = href;
|
|
@@ -5581,7 +5975,7 @@ async function collectCandidateLinks(page, selectors, limit = 12) {
|
|
|
5581
5975
|
if (seenHrefs.has(abs))
|
|
5582
5976
|
continue;
|
|
5583
5977
|
seenHrefs.add(abs);
|
|
5584
|
-
const text = (await el.innerText().catch(() => "")).slice(0, 60).trim();
|
|
5978
|
+
const text = (await withCap(el.innerText().catch(() => ""), 400, "")).slice(0, 60).trim();
|
|
5585
5979
|
out.push({ text, href: abs, selector: sel });
|
|
5586
5980
|
}
|
|
5587
5981
|
} catch {}
|
|
@@ -5600,6 +5994,386 @@ async function fillCep(page, selector, cep) {
|
|
|
5600
5994
|
return false;
|
|
5601
5995
|
}
|
|
5602
5996
|
}
|
|
5997
|
+
var GENERIC_TITLE_PATTERNS = [
|
|
5998
|
+
/^p[áa]gina de produto/i,
|
|
5999
|
+
/^product page/i,
|
|
6000
|
+
/^carregando/i,
|
|
6001
|
+
/^loading/i,
|
|
6002
|
+
/^undefined/i,
|
|
6003
|
+
/^home$/i
|
|
6004
|
+
];
|
|
6005
|
+
function looksGeneric(s) {
|
|
6006
|
+
const t = s.trim();
|
|
6007
|
+
return GENERIC_TITLE_PATTERNS.some((re) => re.test(t));
|
|
6008
|
+
}
|
|
6009
|
+
async function extractProductTitle(page) {
|
|
6010
|
+
await page.waitForSelector("h1", { timeout: 2500, state: "attached" }).catch(() => {
|
|
6011
|
+
return;
|
|
6012
|
+
});
|
|
6013
|
+
const visibleSelectors = [
|
|
6014
|
+
"main h1",
|
|
6015
|
+
"h1[class*='product' i]",
|
|
6016
|
+
"h1.product-title",
|
|
6017
|
+
"[itemprop='name'][data-product-name]",
|
|
6018
|
+
"[data-product-name]",
|
|
6019
|
+
"[data-fs-product-title]",
|
|
6020
|
+
"[itemprop='name']",
|
|
6021
|
+
".vtex-store-components-3-x-productNameContainer",
|
|
6022
|
+
"h1"
|
|
6023
|
+
];
|
|
6024
|
+
for (const sel of visibleSelectors) {
|
|
6025
|
+
try {
|
|
6026
|
+
const el = page.locator(sel).first();
|
|
6027
|
+
const visible = await withCap(el.isVisible({ timeout: 250 }).catch(() => false), 400, false);
|
|
6028
|
+
if (!visible)
|
|
6029
|
+
continue;
|
|
6030
|
+
const text = await withCap(el.innerText().catch(() => ""), 500, "");
|
|
6031
|
+
const clean = text.trim();
|
|
6032
|
+
if (clean.length > 3 && !looksGeneric(clean))
|
|
6033
|
+
return clean;
|
|
6034
|
+
} catch {}
|
|
6035
|
+
}
|
|
6036
|
+
try {
|
|
6037
|
+
const jsonLdName = await withCap(page.evaluate(() => {
|
|
6038
|
+
const scripts = Array.from(document.querySelectorAll('script[type="application/ld+json"]'));
|
|
6039
|
+
for (const s of scripts) {
|
|
6040
|
+
try {
|
|
6041
|
+
const data = JSON.parse(s.textContent ?? "{}");
|
|
6042
|
+
const items = Array.isArray(data) ? data : [data];
|
|
6043
|
+
for (const item of items) {
|
|
6044
|
+
const product = item?.["@graph"]?.find?.((x) => x?.["@type"] === "Product") ?? item;
|
|
6045
|
+
if (product?.["@type"] === "Product" && typeof product.name === "string") {
|
|
6046
|
+
return product.name;
|
|
6047
|
+
}
|
|
6048
|
+
}
|
|
6049
|
+
} catch {}
|
|
6050
|
+
}
|
|
6051
|
+
return null;
|
|
6052
|
+
}), 1000, null);
|
|
6053
|
+
if (jsonLdName && jsonLdName.trim().length > 3 && !looksGeneric(jsonLdName)) {
|
|
6054
|
+
return jsonLdName.trim();
|
|
6055
|
+
}
|
|
6056
|
+
} catch {}
|
|
6057
|
+
try {
|
|
6058
|
+
const og = await withCap(page.locator("meta[property='og:title']").first().getAttribute("content").catch(() => null), 500, null);
|
|
6059
|
+
if (og && og.trim().length > 3 && !looksGeneric(og))
|
|
6060
|
+
return og.trim();
|
|
6061
|
+
} catch {}
|
|
6062
|
+
const docTitle = await withCap(page.title().catch(() => ""), 500, "");
|
|
6063
|
+
if (docTitle.trim().length > 3 && !looksGeneric(docTitle))
|
|
6064
|
+
return docTitle.trim();
|
|
6065
|
+
return null;
|
|
6066
|
+
}
|
|
6067
|
+
async function isCartUiVisible(page) {
|
|
6068
|
+
return firstVisible(page, [
|
|
6069
|
+
"[role='dialog']:visible",
|
|
6070
|
+
"[aria-modal='true']:visible",
|
|
6071
|
+
"[data-minicart][aria-hidden='false']",
|
|
6072
|
+
"[data-minicart-open]",
|
|
6073
|
+
".minicart--open",
|
|
6074
|
+
".minicart-drawer:not([hidden])",
|
|
6075
|
+
"[class*='minicart'][class*='open']",
|
|
6076
|
+
"[class*='cart-drawer'][class*='open']",
|
|
6077
|
+
"[class*='drawer-cart']:visible"
|
|
6078
|
+
]);
|
|
6079
|
+
}
|
|
6080
|
+
async function isCartRevealed(page, expectedProductTitle) {
|
|
6081
|
+
if (expectedProductTitle) {
|
|
6082
|
+
const v = await validateCartContainsTitleQuick(page, expectedProductTitle);
|
|
6083
|
+
if (v)
|
|
6084
|
+
return `title-found:${v}`;
|
|
6085
|
+
}
|
|
6086
|
+
return isCartUiVisible(page);
|
|
6087
|
+
}
|
|
6088
|
+
async function validateCartContainsTitleQuick(page, expectedTitle) {
|
|
6089
|
+
const quickSelectors = [
|
|
6090
|
+
"[role='dialog']",
|
|
6091
|
+
"[class*='minicart' i]",
|
|
6092
|
+
"[class*='cart' i]",
|
|
6093
|
+
"[class*='checkout' i]",
|
|
6094
|
+
"#cart-fixed",
|
|
6095
|
+
"table.cart-items"
|
|
6096
|
+
];
|
|
6097
|
+
for (const scope of quickSelectors) {
|
|
6098
|
+
try {
|
|
6099
|
+
const scopeLoc = page.locator(scope);
|
|
6100
|
+
if (await withCap(scopeLoc.count(), 800, 0) === 0)
|
|
6101
|
+
continue;
|
|
6102
|
+
const text = await withCap(scopeLoc.first().innerText().catch(() => ""), 800, "");
|
|
6103
|
+
if (text && titlesMatch(text, expectedTitle))
|
|
6104
|
+
return scope;
|
|
6105
|
+
} catch {}
|
|
6106
|
+
}
|
|
6107
|
+
return null;
|
|
6108
|
+
}
|
|
6109
|
+
async function dismissOverlays(page, ctx) {
|
|
6110
|
+
const overlaySelectors = [
|
|
6111
|
+
"[class*='cookie' i][class*='banner' i]",
|
|
6112
|
+
"[class*='cookie' i][class*='consent' i]",
|
|
6113
|
+
"[id*='cookie' i][class*='banner' i]",
|
|
6114
|
+
"[role='alertdialog']:visible",
|
|
6115
|
+
"[class*='toast' i]:visible",
|
|
6116
|
+
"[class*='snackbar' i]:visible",
|
|
6117
|
+
"[class*='added-to-cart' i]:visible",
|
|
6118
|
+
"[class*='product-added' i]:visible"
|
|
6119
|
+
];
|
|
6120
|
+
let dismissedAny = false;
|
|
6121
|
+
for (const sel of overlaySelectors) {
|
|
6122
|
+
try {
|
|
6123
|
+
const overlay = page.locator(sel).first();
|
|
6124
|
+
if (!await withCap(overlay.isVisible({ timeout: 200 }).catch(() => false), 400, false))
|
|
6125
|
+
continue;
|
|
6126
|
+
const closer = overlay.locator("button[aria-label*='close' i], button[aria-label*='fechar' i], button[class*='close' i], [data-close], [aria-label='Close']").first();
|
|
6127
|
+
if (await withCap(closer.isVisible({ timeout: 200 }).catch(() => false), 400, false)) {
|
|
6128
|
+
await closer.click({ timeout: 1500 }).catch(() => {
|
|
6129
|
+
return;
|
|
6130
|
+
});
|
|
6131
|
+
dismissedAny = true;
|
|
6132
|
+
continue;
|
|
6133
|
+
}
|
|
6134
|
+
await page.keyboard.press("Escape").catch(() => {
|
|
6135
|
+
return;
|
|
6136
|
+
});
|
|
6137
|
+
dismissedAny = true;
|
|
6138
|
+
} catch {}
|
|
6139
|
+
}
|
|
6140
|
+
if (dismissedAny) {
|
|
6141
|
+
dlog2(ctx, " openMinicart: dismissed overlay(s) before interacting");
|
|
6142
|
+
await page.waitForTimeout(500);
|
|
6143
|
+
}
|
|
6144
|
+
}
|
|
6145
|
+
async function waitForCartHydration(page) {
|
|
6146
|
+
await Promise.race([
|
|
6147
|
+
page.waitForResponse((r) => /\/api\/checkout\/pub\/orderForm|orderForm|cart\/api/i.test(r.url()) && r.ok(), { timeout: 8000 }).catch(() => {
|
|
6148
|
+
return;
|
|
6149
|
+
}),
|
|
6150
|
+
page.waitForSelector(".cart-items, [class*='cart-item' i], #cart-fixed .item, [data-cart-item]", { timeout: 8000 }).catch(() => {
|
|
6151
|
+
return;
|
|
6152
|
+
})
|
|
6153
|
+
]);
|
|
6154
|
+
await page.waitForTimeout(800);
|
|
6155
|
+
}
|
|
6156
|
+
async function openMinicart(page, trigger, ctx, expectedProductTitle) {
|
|
6157
|
+
const beforeUrl = page.url();
|
|
6158
|
+
await dismissOverlays(page, ctx);
|
|
6159
|
+
await page.waitForTimeout(800);
|
|
6160
|
+
const alreadyOpen = await isCartRevealed(page, expectedProductTitle);
|
|
6161
|
+
if (alreadyOpen) {
|
|
6162
|
+
dlog2(ctx, ` openMinicart: already-open (matched ${alreadyOpen})`);
|
|
6163
|
+
return { method: "already-open", url: beforeUrl, visibleMarker: alreadyOpen };
|
|
6164
|
+
}
|
|
6165
|
+
const triggerHref = await trigger.locator.getAttribute("href").catch(() => null);
|
|
6166
|
+
const hrefHasCartTarget = !!triggerHref && /\/(checkout|cart|carrinho)/i.test(triggerHref);
|
|
6167
|
+
if (ctx.viewport === "desktop") {
|
|
6168
|
+
dlog2(ctx, ` openMinicart: trying hover first on ${trigger.selector}${triggerHref ? ` (href=${triggerHref})` : ""}`);
|
|
6169
|
+
await trigger.locator.hover({ timeout: 3000 }).catch(() => {
|
|
6170
|
+
return;
|
|
6171
|
+
});
|
|
6172
|
+
await page.waitForTimeout(1500);
|
|
6173
|
+
const hoverOpened = await isCartRevealed(page, expectedProductTitle);
|
|
6174
|
+
if (hoverOpened) {
|
|
6175
|
+
dlog2(ctx, ` openMinicart: hover opened drawer (${hoverOpened})`);
|
|
6176
|
+
return { method: "hover", url: page.url(), visibleMarker: hoverOpened };
|
|
6177
|
+
}
|
|
6178
|
+
}
|
|
6179
|
+
if (ctx.viewport === "mobile") {
|
|
6180
|
+
dlog2(ctx, ` openMinicart: trying tap (mobile) on ${trigger.selector}`);
|
|
6181
|
+
await Promise.all([
|
|
6182
|
+
page.waitForURL((url) => url.toString() !== beforeUrl, { timeout: 4000 }).catch(() => {
|
|
6183
|
+
return;
|
|
6184
|
+
}),
|
|
6185
|
+
trigger.locator.tap({ timeout: 4000 }).catch(() => {
|
|
6186
|
+
return;
|
|
6187
|
+
})
|
|
6188
|
+
]);
|
|
6189
|
+
if (page.url() !== beforeUrl) {
|
|
6190
|
+
await page.waitForLoadState("load", { timeout: 8000 }).catch(() => {
|
|
6191
|
+
return;
|
|
6192
|
+
});
|
|
6193
|
+
await waitForCartHydration(page);
|
|
6194
|
+
dlog2(ctx, ` openMinicart: tap navigated → ${page.url()} (settled)`);
|
|
6195
|
+
return { method: "click-navigate", url: page.url(), visibleMarker: null };
|
|
6196
|
+
}
|
|
6197
|
+
await page.waitForTimeout(800);
|
|
6198
|
+
const tapOpened = await isCartRevealed(page, expectedProductTitle);
|
|
6199
|
+
if (tapOpened) {
|
|
6200
|
+
dlog2(ctx, ` openMinicart: tap opened drawer (${tapOpened})`);
|
|
6201
|
+
return { method: "click", url: page.url(), visibleMarker: tapOpened };
|
|
6202
|
+
}
|
|
6203
|
+
}
|
|
6204
|
+
dlog2(ctx, ` openMinicart: trying force-click on ${trigger.selector}${triggerHref ? ` (href=${triggerHref})` : ""}`);
|
|
6205
|
+
await Promise.all([
|
|
6206
|
+
page.waitForURL((url) => url.toString() !== beforeUrl, { timeout: 4000 }).catch(() => {
|
|
6207
|
+
return;
|
|
6208
|
+
}),
|
|
6209
|
+
trigger.locator.click({ force: true, timeout: 4000 }).catch(() => {
|
|
6210
|
+
return;
|
|
6211
|
+
})
|
|
6212
|
+
]);
|
|
6213
|
+
const afterClickUrl = page.url();
|
|
6214
|
+
if (afterClickUrl !== beforeUrl) {
|
|
6215
|
+
await page.waitForLoadState("load", { timeout: 8000 }).catch(() => {
|
|
6216
|
+
return;
|
|
6217
|
+
});
|
|
6218
|
+
await waitForCartHydration(page);
|
|
6219
|
+
dlog2(ctx, ` openMinicart: click navigated → ${page.url()} (settled)`);
|
|
6220
|
+
return { method: "click-navigate", url: page.url(), visibleMarker: null };
|
|
6221
|
+
}
|
|
6222
|
+
await page.waitForTimeout(1500);
|
|
6223
|
+
const clickOpened = await isCartRevealed(page, expectedProductTitle);
|
|
6224
|
+
if (clickOpened) {
|
|
6225
|
+
dlog2(ctx, ` openMinicart: click opened drawer (${clickOpened})`);
|
|
6226
|
+
return { method: "click", url: afterClickUrl, visibleMarker: clickOpened };
|
|
6227
|
+
}
|
|
6228
|
+
if (ctx.viewport !== "desktop") {
|
|
6229
|
+
dlog2(ctx, ` openMinicart: click didn't reveal cart, trying hover (mobile)`);
|
|
6230
|
+
await trigger.locator.hover({ timeout: 3000 }).catch(() => {
|
|
6231
|
+
return;
|
|
6232
|
+
});
|
|
6233
|
+
await page.waitForTimeout(1500);
|
|
6234
|
+
const hoverOpened = await isCartRevealed(page, expectedProductTitle);
|
|
6235
|
+
if (hoverOpened) {
|
|
6236
|
+
dlog2(ctx, ` openMinicart: hover opened drawer (${hoverOpened})`);
|
|
6237
|
+
return { method: "hover", url: page.url(), visibleMarker: hoverOpened };
|
|
6238
|
+
}
|
|
6239
|
+
}
|
|
6240
|
+
if (hrefHasCartTarget && triggerHref) {
|
|
6241
|
+
const targetUrl = (() => {
|
|
6242
|
+
try {
|
|
6243
|
+
return new URL(triggerHref, page.url()).toString();
|
|
6244
|
+
} catch {
|
|
6245
|
+
return null;
|
|
6246
|
+
}
|
|
6247
|
+
})();
|
|
6248
|
+
if (targetUrl) {
|
|
6249
|
+
dlog2(ctx, ` openMinicart: all interactive strategies failed but trigger has cart href, navigating directly to ${targetUrl}`);
|
|
6250
|
+
await page.goto(targetUrl, { waitUntil: "load", timeout: 15000 }).catch(() => {
|
|
6251
|
+
return;
|
|
6252
|
+
});
|
|
6253
|
+
await waitForCartHydration(page);
|
|
6254
|
+
if (page.url() !== beforeUrl) {
|
|
6255
|
+
dlog2(ctx, ` openMinicart: goto fallback landed on ${page.url()}`);
|
|
6256
|
+
return { method: "click-navigate", url: page.url(), visibleMarker: null };
|
|
6257
|
+
}
|
|
6258
|
+
}
|
|
6259
|
+
}
|
|
6260
|
+
dlog2(ctx, " openMinicart: failed — no cart revealed by hover/click/goto");
|
|
6261
|
+
return { method: "failed", url: page.url(), visibleMarker: null };
|
|
6262
|
+
}
|
|
6263
|
+
function normalizeTitle(s) {
|
|
6264
|
+
return s.toLowerCase().replace(/[®©™]/g, "").replace(/[^\p{L}\p{N}\s]+/gu, " ").replace(/\s+/g, " ").trim();
|
|
6265
|
+
}
|
|
6266
|
+
function titlesMatch(observed, expected) {
|
|
6267
|
+
const o = normalizeTitle(observed);
|
|
6268
|
+
const e = normalizeTitle(expected);
|
|
6269
|
+
if (!o || !e)
|
|
6270
|
+
return false;
|
|
6271
|
+
if (o === e)
|
|
6272
|
+
return true;
|
|
6273
|
+
if (e.length >= 12 && o.includes(e))
|
|
6274
|
+
return true;
|
|
6275
|
+
if (o.length >= 12 && e.includes(o))
|
|
6276
|
+
return true;
|
|
6277
|
+
return false;
|
|
6278
|
+
}
|
|
6279
|
+
async function validateCartContainsTitle(page, expectedTitle, ctx) {
|
|
6280
|
+
const titleSelectors = [
|
|
6281
|
+
"[data-cart-item-name]",
|
|
6282
|
+
"[data-cart-item] [class*='title' i]",
|
|
6283
|
+
"[data-cart-item] [class*='name' i]",
|
|
6284
|
+
"[class*='cart' i] [data-product-name]",
|
|
6285
|
+
"[role='dialog'] [data-product-name]",
|
|
6286
|
+
"[class*='checkout' i] [data-product-name]",
|
|
6287
|
+
"[class*='minicart' i] [data-product-name]",
|
|
6288
|
+
"[data-testid='cart-item-name']",
|
|
6289
|
+
"[data-testid='product-name']",
|
|
6290
|
+
"[role='dialog'] li [class*='product' i]",
|
|
6291
|
+
"[role='dialog'] li [class*='name' i]",
|
|
6292
|
+
"[role='dialog'] li [class*='title' i]",
|
|
6293
|
+
"[role='dialog'] a[href*='/p']",
|
|
6294
|
+
"[class*='minicart' i] [class*='item' i] [class*='name' i]",
|
|
6295
|
+
"[class*='minicart' i] [class*='item' i] [class*='title' i]",
|
|
6296
|
+
"[class*='cart-item' i] [class*='name' i]",
|
|
6297
|
+
"[class*='cart-item' i] [class*='title' i]",
|
|
6298
|
+
"[class*='checkout' i] [class*='product' i] [class*='name' i]",
|
|
6299
|
+
".vtex-minicart-2-x-itemNameContainer",
|
|
6300
|
+
".vtex-checkout-summary-0-x-itemName",
|
|
6301
|
+
".product-name",
|
|
6302
|
+
".item-name",
|
|
6303
|
+
"a.product-name",
|
|
6304
|
+
".cart-items .item-name",
|
|
6305
|
+
"tr.product-item .item-name",
|
|
6306
|
+
"tr.cart-item .item-name",
|
|
6307
|
+
"table.cart-items td a",
|
|
6308
|
+
"#cart-fixed .item .product-name",
|
|
6309
|
+
"#cart-fixed .item-name",
|
|
6310
|
+
".cart-fixed .item-name",
|
|
6311
|
+
".cart-fixed .product-name",
|
|
6312
|
+
"#cart-fixed li a",
|
|
6313
|
+
"#minicart-content .item-name",
|
|
6314
|
+
"[data-fs-cart-item-summary-title]",
|
|
6315
|
+
"[data-fs-cart-item-image] + * a",
|
|
6316
|
+
"[class*='cart' i] a[href*='/p']",
|
|
6317
|
+
"[class*='checkout' i] a[href*='/p']"
|
|
6318
|
+
];
|
|
6319
|
+
const sweepTitles = async () => {
|
|
6320
|
+
const observed2 = [];
|
|
6321
|
+
for (const sel of titleSelectors) {
|
|
6322
|
+
try {
|
|
6323
|
+
const loc = page.locator(sel);
|
|
6324
|
+
const count = await withCap(loc.count(), 1000, 0);
|
|
6325
|
+
const limit = Math.min(count, 10);
|
|
6326
|
+
for (let i = 0;i < limit; i++) {
|
|
6327
|
+
const el = loc.nth(i);
|
|
6328
|
+
const visible = await withCap(el.isVisible({ timeout: 200 }).catch(() => false), 400, false);
|
|
6329
|
+
if (!visible)
|
|
6330
|
+
continue;
|
|
6331
|
+
const text = await withCap(el.innerText().catch(() => ""), 500, "");
|
|
6332
|
+
const clean = text.trim().slice(0, 200);
|
|
6333
|
+
if (clean.length > 2)
|
|
6334
|
+
observed2.push(clean);
|
|
6335
|
+
}
|
|
6336
|
+
if (observed2.length > 0)
|
|
6337
|
+
return observed2;
|
|
6338
|
+
} catch {}
|
|
6339
|
+
}
|
|
6340
|
+
return observed2;
|
|
6341
|
+
};
|
|
6342
|
+
let observed = await sweepTitles();
|
|
6343
|
+
if (observed.length === 0) {
|
|
6344
|
+
dlog2(ctx, " validateCartContainsTitle: 0 titles on first pass, retrying after 2s");
|
|
6345
|
+
await page.waitForTimeout(2000);
|
|
6346
|
+
observed = await sweepTitles();
|
|
6347
|
+
}
|
|
6348
|
+
dlog2(ctx, ` validateCartContainsTitle: observed ${observed.length} titles ${observed.length ? `[${observed.slice(0, 3).map((t) => t.slice(0, 30)).join(" | ")}${observed.length > 3 ? " | …" : ""}]` : ""}`);
|
|
6349
|
+
if (observed.length === 0) {
|
|
6350
|
+
return { found: false, observedTitles: [], method: "none" };
|
|
6351
|
+
}
|
|
6352
|
+
const found = observed.some((o) => titlesMatch(o, expectedTitle));
|
|
6353
|
+
return { found, observedTitles: observed, method: "selector" };
|
|
6354
|
+
}
|
|
6355
|
+
async function detectEmptyCartBanner(page) {
|
|
6356
|
+
const bannerSelectors = [
|
|
6357
|
+
":text-matches('carrinho.*vazio', 'i')",
|
|
6358
|
+
":text-matches('seu carrinho está vazio', 'i')",
|
|
6359
|
+
":text-matches('empty cart', 'i')",
|
|
6360
|
+
":text-matches('cart is empty', 'i')",
|
|
6361
|
+
":text-matches('nenhum item.*carrinho', 'i')",
|
|
6362
|
+
"[class*='empty' i][class*='cart' i]",
|
|
6363
|
+
"[class*='cart-empty' i]"
|
|
6364
|
+
];
|
|
6365
|
+
for (const sel of bannerSelectors) {
|
|
6366
|
+
try {
|
|
6367
|
+
const loc = page.locator(sel).first();
|
|
6368
|
+
if (await withCap(loc.isVisible({ timeout: 300 }).catch(() => false), 500, false)) {
|
|
6369
|
+
const text = await withCap(loc.innerText().catch(() => ""), 500, "");
|
|
6370
|
+
if (text.trim())
|
|
6371
|
+
return text.trim().slice(0, 120);
|
|
6372
|
+
}
|
|
6373
|
+
} catch {}
|
|
6374
|
+
}
|
|
6375
|
+
return null;
|
|
6376
|
+
}
|
|
5603
6377
|
async function attemptRecovery(page, _ctx, stepName, intendedAction, alreadyTried) {
|
|
5604
6378
|
let html = "";
|
|
5605
6379
|
try {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/parity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "E2E parity validator for site migrations. Compares prod vs cand and reports UI, functional, SEO, visual, and Web Vitals deltas with an LLM-ranked HTML report.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|