@decocms/parity 0.3.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 +27 -0
- package/dist/cli.js +728 -34
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ 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
|
+
|
|
8
35
|
## [0.3.0](https://github.com/decocms/parity/compare/v0.2.0...v0.3.0) (2026-05-26)
|
|
9
36
|
|
|
10
37
|
|
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,6 +5183,15 @@ 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
|
}
|
|
@@ -5094,7 +5230,7 @@ async function runFlow(flow, ctx) {
|
|
|
5094
5230
|
resolve(finalize(flow, ctx, [], [
|
|
5095
5231
|
{
|
|
5096
5232
|
step: 0,
|
|
5097
|
-
name: "
|
|
5233
|
+
name: "flow-timeout",
|
|
5098
5234
|
side: ctx.side,
|
|
5099
5235
|
viewport: ctx.viewport,
|
|
5100
5236
|
status: "failed",
|
|
@@ -5103,7 +5239,14 @@ async function runFlow(flow, ctx) {
|
|
|
5103
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.`
|
|
5104
5240
|
}
|
|
5105
5241
|
], start));
|
|
5106
|
-
|
|
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));
|
|
5107
5250
|
}, deadlineMs);
|
|
5108
5251
|
});
|
|
5109
5252
|
try {
|
|
@@ -5219,12 +5362,15 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5219
5362
|
};
|
|
5220
5363
|
try {
|
|
5221
5364
|
reportStart(1, "visit-home");
|
|
5365
|
+
dlog2(ctx, `step 1 visit-home: capturePage(${ctx.baseUrl}) start (scrollToLoad=false)`);
|
|
5222
5366
|
const homeCap = await capturePage(page, {
|
|
5223
5367
|
url: ctx.baseUrl,
|
|
5224
5368
|
side: ctx.side,
|
|
5225
5369
|
viewport: ctx.viewport,
|
|
5226
|
-
screenshotPath: screenshotPath(ctx, "pj-1-home")
|
|
5370
|
+
screenshotPath: screenshotPath(ctx, "pj-1-home"),
|
|
5371
|
+
scrollToLoad: false
|
|
5227
5372
|
});
|
|
5373
|
+
dlog2(ctx, `step 1 visit-home: capturePage done status=${homeCap.status}`);
|
|
5228
5374
|
pages.push(homeCap);
|
|
5229
5375
|
const step1Status = homeCap.status >= 200 && homeCap.status < 400 ? "ok" : "failed";
|
|
5230
5376
|
steps.push({
|
|
@@ -5243,19 +5389,24 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5243
5389
|
return { pages, steps };
|
|
5244
5390
|
}
|
|
5245
5391
|
reportStart(2, "navigate-plp");
|
|
5392
|
+
dlog2(ctx, `step 2 navigate-plp: findCategoryUrl start (hint=${ctx.rc.plpUrlHint ?? "—"})`);
|
|
5246
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"}`);
|
|
5247
5395
|
if (!plpHit) {
|
|
5248
5396
|
steps.push(makeSkipStep(2, "navigate-plp", ctx, "no category link found"));
|
|
5249
5397
|
reportEnd(2, "navigate-plp", "skipped", 0, "no category link found");
|
|
5250
5398
|
return { pages, steps };
|
|
5251
5399
|
}
|
|
5252
5400
|
const t2 = Date.now();
|
|
5401
|
+
dlog2(ctx, `step 2 navigate-plp: capturePage(${plpHit.url}) start (scrollToLoad=false)`);
|
|
5253
5402
|
const plpCap = await capturePage(page, {
|
|
5254
5403
|
url: plpHit.url,
|
|
5255
5404
|
side: ctx.side,
|
|
5256
5405
|
viewport: ctx.viewport,
|
|
5257
|
-
screenshotPath: screenshotPath(ctx, "pj-2-plp")
|
|
5406
|
+
screenshotPath: screenshotPath(ctx, "pj-2-plp"),
|
|
5407
|
+
scrollToLoad: false
|
|
5258
5408
|
});
|
|
5409
|
+
dlog2(ctx, `step 2 navigate-plp: capturePage done status=${plpCap.status}`);
|
|
5259
5410
|
pages.push(plpCap);
|
|
5260
5411
|
const step2Status = plpCap.status >= 200 && plpCap.status < 400 ? "ok" : "failed";
|
|
5261
5412
|
steps.push({
|
|
@@ -5285,7 +5436,8 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5285
5436
|
url: pdpHit.url,
|
|
5286
5437
|
side: ctx.side,
|
|
5287
5438
|
viewport: ctx.viewport,
|
|
5288
|
-
screenshotPath: screenshotPath(ctx, "pj-3-pdp")
|
|
5439
|
+
screenshotPath: screenshotPath(ctx, "pj-3-pdp"),
|
|
5440
|
+
scrollToLoad: false
|
|
5289
5441
|
});
|
|
5290
5442
|
pages.push(pdpCap);
|
|
5291
5443
|
const step3Status = pdpCap.status >= 200 && pdpCap.status < 400 ? "ok" : "failed";
|
|
@@ -5304,6 +5456,14 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5304
5456
|
reportEnd(3, "enter-pdp", step3Status, Date.now() - t3);
|
|
5305
5457
|
steps[steps.length - 1].actionDescription = `Abriu PDP \`${pdpHit.url}\` (via \`${pdpHit.selector}\`)`;
|
|
5306
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
|
+
}
|
|
5307
5467
|
reportStart(4, "shipping-calc-pdp");
|
|
5308
5468
|
let cepInputPdp = await firstVisible(page, selFor(ctx, "cepInputPdp"));
|
|
5309
5469
|
let cepPdpRecovered = false;
|
|
@@ -5415,35 +5575,68 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5415
5575
|
recoveryBudget--;
|
|
5416
5576
|
}
|
|
5417
5577
|
}
|
|
5578
|
+
let cartOpenMethod = "failed";
|
|
5418
5579
|
let miniText = "";
|
|
5419
5580
|
if (miniHit) {
|
|
5420
5581
|
miniText = await miniHit.locator.innerText().catch(() => "");
|
|
5421
|
-
await
|
|
5422
|
-
|
|
5423
|
-
|
|
5424
|
-
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");
|
|
5425
5615
|
}
|
|
5426
5616
|
const sp6 = screenshotPath(ctx, "pj-6-minicart");
|
|
5427
5617
|
await page.screenshot({ path: sp6, fullPage: false }).catch(() => {
|
|
5428
5618
|
return;
|
|
5429
5619
|
});
|
|
5620
|
+
const step6Status = cartOpenMethod === "failed" ? "failed" : step6Validation && !step6Validation.found ? "failed" : "ok";
|
|
5430
5621
|
steps.push({
|
|
5431
5622
|
step: 6,
|
|
5432
5623
|
name: "open-minicart",
|
|
5433
5624
|
side: ctx.side,
|
|
5434
5625
|
viewport: ctx.viewport,
|
|
5435
|
-
status:
|
|
5626
|
+
status: step6Status,
|
|
5436
5627
|
durationMs: Date.now() - t6,
|
|
5437
5628
|
url: page.url(),
|
|
5438
5629
|
screenshotPath: sp6,
|
|
5439
5630
|
screenshotBeforePath: spBefore6,
|
|
5440
5631
|
beforeUrl: beforeUrl6,
|
|
5441
|
-
|
|
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",
|
|
5442
5635
|
selectorKey: miniHit ? "minicartTrigger" : undefined,
|
|
5443
5636
|
usedSelector: miniHit?.selector,
|
|
5444
5637
|
recoveredByLlm: miniRecovered || undefined
|
|
5445
5638
|
});
|
|
5446
|
-
reportEnd(6, "open-minicart",
|
|
5639
|
+
reportEnd(6, "open-minicart", step6Status, Date.now() - t6);
|
|
5447
5640
|
reportStart(7, "shipping-calc-cart");
|
|
5448
5641
|
let cepInputCart = await firstVisible(page, selFor(ctx, "cepInputCart"));
|
|
5449
5642
|
let cepCartRecovered = false;
|
|
@@ -5491,10 +5684,71 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5491
5684
|
reportEnd(7, "shipping-calc-cart", "skipped", 0, "no CEP input in cart");
|
|
5492
5685
|
}
|
|
5493
5686
|
reportStart(8, "go-checkout");
|
|
5494
|
-
|
|
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"}`);
|
|
5495
5746
|
let checkoutRecovered = false;
|
|
5496
5747
|
if (!checkoutHit && recoveryBudget > 0) {
|
|
5497
|
-
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"}`);
|
|
5498
5752
|
if (recovery) {
|
|
5499
5753
|
checkoutHit = recovery;
|
|
5500
5754
|
checkoutRecovered = true;
|
|
@@ -5502,6 +5756,7 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5502
5756
|
}
|
|
5503
5757
|
}
|
|
5504
5758
|
if (!checkoutHit) {
|
|
5759
|
+
dlog2(ctx, "step 8 go-checkout: no button found, skipping");
|
|
5505
5760
|
steps.push(makeSkipStep(8, "go-checkout", ctx, "no checkout button found (recovery exhausted)"));
|
|
5506
5761
|
reportEnd(8, "go-checkout", "skipped", 0, "no checkout button found");
|
|
5507
5762
|
return { pages, steps };
|
|
@@ -5512,8 +5767,10 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5512
5767
|
return;
|
|
5513
5768
|
});
|
|
5514
5769
|
const clickedText2 = await hit.locator.innerText().catch(() => "");
|
|
5770
|
+
const urlBefore = page.url();
|
|
5771
|
+
const urlChangePredicate = alreadyInCheckoutPage ? (u) => u.toString() !== urlBefore : /checkout/i;
|
|
5515
5772
|
await Promise.all([
|
|
5516
|
-
page.waitForURL(
|
|
5773
|
+
page.waitForURL(urlChangePredicate, { timeout: 1e4 }).catch(() => {
|
|
5517
5774
|
return;
|
|
5518
5775
|
}),
|
|
5519
5776
|
hit.locator.click({ timeout: 5000 }).catch(() => {
|
|
@@ -5529,25 +5786,62 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5529
5786
|
};
|
|
5530
5787
|
const t8 = Date.now();
|
|
5531
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
|
+
};
|
|
5532
5795
|
let attempt = 1;
|
|
5796
|
+
dlog2(ctx, `step 8 go-checkout: tryCheckoutClick attempt 1 — selector=${checkoutHit.selector}, beforeUrl=${beforeUrl8}`);
|
|
5533
5797
|
let result = await tryCheckoutClick(checkoutHit, attempt);
|
|
5534
|
-
let reachedCheckout =
|
|
5798
|
+
let reachedCheckout = isReachedCheckout(result.url);
|
|
5535
5799
|
let usedSelector = checkoutHit.selector;
|
|
5536
5800
|
let clickedText = result.clickedText;
|
|
5537
|
-
|
|
5538
|
-
|
|
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"}`);
|
|
5539
5807
|
if (retrySuggestion) {
|
|
5540
5808
|
recoveryBudget--;
|
|
5541
5809
|
checkoutRecovered = true;
|
|
5542
5810
|
attempt++;
|
|
5811
|
+
dlog2(ctx, `step 8 go-checkout: tryCheckoutClick attempt 2 — selector=${retrySuggestion.selector}`);
|
|
5543
5812
|
const retryResult = await tryCheckoutClick(retrySuggestion, attempt);
|
|
5544
5813
|
result = retryResult;
|
|
5545
5814
|
usedSelector = retrySuggestion.selector;
|
|
5546
5815
|
clickedText = retryResult.clickedText;
|
|
5547
|
-
reachedCheckout =
|
|
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()}'`);
|
|
5548
5818
|
}
|
|
5819
|
+
} else if (!reachedCheckout) {
|
|
5820
|
+
dlog2(ctx, `step 8 go-checkout: not retrying — recoveryBudget=${recoveryBudget}`);
|
|
5549
5821
|
}
|
|
5550
|
-
|
|
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";
|
|
5551
5845
|
steps.push({
|
|
5552
5846
|
step: 8,
|
|
5553
5847
|
name: "go-checkout",
|
|
@@ -5559,7 +5853,8 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5559
5853
|
screenshotPath: result.spAfter,
|
|
5560
5854
|
screenshotBeforePath: result.spBefore,
|
|
5561
5855
|
beforeUrl: beforeUrl8,
|
|
5562
|
-
|
|
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)` : ""}`,
|
|
5563
5858
|
selectorKey: "checkoutButton",
|
|
5564
5859
|
usedSelector,
|
|
5565
5860
|
recoveredByLlm: checkoutRecovered || undefined
|
|
@@ -5584,10 +5879,15 @@ function makeSkipStep(step, name, ctx, note) {
|
|
|
5584
5879
|
}
|
|
5585
5880
|
async function findCategoryUrl(page, ctx) {
|
|
5586
5881
|
const selectors = selFor(ctx, "categoryLink");
|
|
5882
|
+
dlog2(ctx, ` findCategoryUrl: collectCandidateLinks(${selectors.length} selectors) start`);
|
|
5883
|
+
const t0 = Date.now();
|
|
5587
5884
|
const candidates = await collectCandidateLinks(page, selectors, 12);
|
|
5885
|
+
dlog2(ctx, ` findCategoryUrl: collectCandidateLinks done in ${Date.now() - t0}ms → ${candidates.length} candidates`);
|
|
5588
5886
|
if (candidates.length === 0)
|
|
5589
5887
|
return null;
|
|
5888
|
+
dlog2(ctx, " findCategoryUrl: pickCategoryLink LLM call start");
|
|
5590
5889
|
const picked = await pickCategoryLink(candidates.map((c) => ({ text: c.text, href: c.href })));
|
|
5890
|
+
dlog2(ctx, ` findCategoryUrl: pickCategoryLink LLM done → ${picked?.href ?? "null"}`);
|
|
5591
5891
|
if (!picked)
|
|
5592
5892
|
return null;
|
|
5593
5893
|
const original = candidates.find((c) => c.href === picked.href);
|
|
@@ -5636,20 +5936,34 @@ async function firstVisibleLocator(page, selectors) {
|
|
|
5636
5936
|
}
|
|
5637
5937
|
return null;
|
|
5638
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
|
+
}
|
|
5639
5946
|
async function collectCandidateLinks(page, selectors, limit = 12) {
|
|
5640
5947
|
const out = [];
|
|
5641
5948
|
const seenHrefs = new Set;
|
|
5949
|
+
const deadline = Date.now() + COLLECT_CANDIDATES_BUDGET_MS;
|
|
5950
|
+
const expired = () => Date.now() >= deadline;
|
|
5642
5951
|
for (const sel of selectors) {
|
|
5643
5952
|
if (out.length >= limit)
|
|
5644
5953
|
break;
|
|
5954
|
+
if (expired())
|
|
5955
|
+
break;
|
|
5645
5956
|
try {
|
|
5646
5957
|
const elements = page.locator(sel);
|
|
5647
|
-
const count = await elements.count();
|
|
5958
|
+
const count = await withCap(elements.count(), 1000, 0);
|
|
5648
5959
|
for (let i = 0;i < count && out.length < limit; i++) {
|
|
5960
|
+
if (expired())
|
|
5961
|
+
break;
|
|
5649
5962
|
const el = elements.nth(i);
|
|
5650
|
-
|
|
5963
|
+
const visible = await withCap(el.isVisible({ timeout: 250 }).catch(() => false), 400, false);
|
|
5964
|
+
if (!visible)
|
|
5651
5965
|
continue;
|
|
5652
|
-
const href = await el.getAttribute("href").catch(() => null);
|
|
5966
|
+
const href = await withCap(el.getAttribute("href").catch(() => null), 400, null);
|
|
5653
5967
|
if (!href)
|
|
5654
5968
|
continue;
|
|
5655
5969
|
let abs = href;
|
|
@@ -5661,7 +5975,7 @@ async function collectCandidateLinks(page, selectors, limit = 12) {
|
|
|
5661
5975
|
if (seenHrefs.has(abs))
|
|
5662
5976
|
continue;
|
|
5663
5977
|
seenHrefs.add(abs);
|
|
5664
|
-
const text = (await el.innerText().catch(() => "")).slice(0, 60).trim();
|
|
5978
|
+
const text = (await withCap(el.innerText().catch(() => ""), 400, "")).slice(0, 60).trim();
|
|
5665
5979
|
out.push({ text, href: abs, selector: sel });
|
|
5666
5980
|
}
|
|
5667
5981
|
} catch {}
|
|
@@ -5680,6 +5994,386 @@ async function fillCep(page, selector, cep) {
|
|
|
5680
5994
|
return false;
|
|
5681
5995
|
}
|
|
5682
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
|
+
}
|
|
5683
6377
|
async function attemptRecovery(page, _ctx, stepName, intendedAction, alreadyTried) {
|
|
5684
6378
|
let html = "";
|
|
5685
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",
|