@decocms/parity 0.3.0 → 0.5.4
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 +52 -0
- package/dist/cli.js +1517 -211
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,21 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
-
var __create = Object.create;
|
|
4
|
-
var __getProtoOf = Object.getPrototypeOf;
|
|
5
|
-
var __defProp = Object.defineProperty;
|
|
6
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
-
var __toESM = (mod, isNodeMode, target) => {
|
|
9
|
-
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
10
|
-
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
11
|
-
for (let key of __getOwnPropNames(mod))
|
|
12
|
-
if (!__hasOwnProp.call(to, key))
|
|
13
|
-
__defProp(to, key, {
|
|
14
|
-
get: () => mod[key],
|
|
15
|
-
enumerable: true
|
|
16
|
-
});
|
|
17
|
-
return to;
|
|
18
|
-
};
|
|
19
3
|
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
20
4
|
|
|
21
5
|
// src/cli.ts
|
|
@@ -99,7 +83,15 @@ var StepCapture = z.object({
|
|
|
99
83
|
actionDescription: z.string().optional(),
|
|
100
84
|
beforeUrl: z.string().optional(),
|
|
101
85
|
screenshotBeforePath: z.string().optional(),
|
|
102
|
-
tracePath: z.string().optional()
|
|
86
|
+
tracePath: z.string().optional(),
|
|
87
|
+
cartValidation: z.object({
|
|
88
|
+
expectedTitle: z.string(),
|
|
89
|
+
found: z.boolean(),
|
|
90
|
+
method: z.enum(["selector", "llm", "none"]).optional(),
|
|
91
|
+
observedTitles: z.array(z.string()).optional(),
|
|
92
|
+
reason: z.string().optional()
|
|
93
|
+
}).optional(),
|
|
94
|
+
cartOpenMethod: z.enum(["click", "click-navigate", "hover", "already-open", "failed"]).optional()
|
|
103
95
|
});
|
|
104
96
|
var FlowCapture = z.object({
|
|
105
97
|
flow: FlowName,
|
|
@@ -294,7 +286,13 @@ var ParityRc = z.object({
|
|
|
294
286
|
minicartTrigger: z.string().optional(),
|
|
295
287
|
cepInputPdp: z.string().optional(),
|
|
296
288
|
cepInputCart: z.string().optional(),
|
|
297
|
-
checkoutButton: z.string().optional()
|
|
289
|
+
checkoutButton: z.string().optional(),
|
|
290
|
+
sizeSwatch: z.string().optional(),
|
|
291
|
+
colorSwatch: z.string().optional(),
|
|
292
|
+
variantRow: z.string().optional(),
|
|
293
|
+
quantityIncrement: z.string().optional(),
|
|
294
|
+
quantityInput: z.string().optional(),
|
|
295
|
+
minicartCount: z.string().optional()
|
|
298
296
|
}).default({}),
|
|
299
297
|
skipSteps: z.array(z.string()).default([])
|
|
300
298
|
});
|
|
@@ -931,6 +929,15 @@ async function stopTracing(ctx, path) {
|
|
|
931
929
|
}
|
|
932
930
|
|
|
933
931
|
// src/engine/collect.ts
|
|
932
|
+
var DEBUG_PARITY = process.env.DEBUG_PARITY === "1" || process.env.DEBUG_PARITY === "true";
|
|
933
|
+
var DEBUG_START = Date.now();
|
|
934
|
+
function dlog(side, viewport, msg) {
|
|
935
|
+
if (!DEBUG_PARITY)
|
|
936
|
+
return;
|
|
937
|
+
const elapsed = ((Date.now() - DEBUG_START) / 1000).toFixed(1);
|
|
938
|
+
process.stderr.write(`[+${elapsed}s ${viewport}/${side}] ${msg}
|
|
939
|
+
`);
|
|
940
|
+
}
|
|
934
941
|
var VITALS_INIT_SCRIPT = `
|
|
935
942
|
(function() {
|
|
936
943
|
if (window.__parity_vitals_installed) return;
|
|
@@ -1141,10 +1148,12 @@ async function capturePage(page, opts) {
|
|
|
1141
1148
|
let html = "";
|
|
1142
1149
|
const inner = async () => {
|
|
1143
1150
|
try {
|
|
1151
|
+
dlog(opts.side, opts.viewport, ` capturePage: goto(${opts.url}) start`);
|
|
1144
1152
|
response = await page.goto(opts.url, {
|
|
1145
1153
|
waitUntil: "domcontentloaded",
|
|
1146
1154
|
timeout: Math.min(opts.timeoutMs ?? 30000, remaining())
|
|
1147
1155
|
});
|
|
1156
|
+
dlog(opts.side, opts.viewport, ` capturePage: goto done status=${response?.status() ?? "?"} (remaining=${remaining()}ms)`);
|
|
1148
1157
|
finalUrl = page.url();
|
|
1149
1158
|
if (response) {
|
|
1150
1159
|
const headers = response.headers();
|
|
@@ -1156,20 +1165,25 @@ async function capturePage(page, opts) {
|
|
|
1156
1165
|
});
|
|
1157
1166
|
await page.waitForTimeout(Math.min(opts.settleMs ?? 1200, remaining()));
|
|
1158
1167
|
} else {
|
|
1168
|
+
dlog(opts.side, opts.viewport, ` capturePage: waitForLoadState('load') (cap=${Math.min(12000, remaining())}ms)`);
|
|
1159
1169
|
await page.waitForLoadState("load", { timeout: Math.min(12000, remaining()) }).catch(() => {
|
|
1160
1170
|
return;
|
|
1161
1171
|
});
|
|
1172
|
+
dlog(opts.side, opts.viewport, ` capturePage: waitForLoadState('networkidle') (cap=${Math.min(6000, remaining())}ms)`);
|
|
1162
1173
|
await page.waitForLoadState("networkidle", { timeout: Math.min(6000, remaining()) }).catch(() => {
|
|
1163
1174
|
return;
|
|
1164
1175
|
});
|
|
1176
|
+
dlog(opts.side, opts.viewport, ` capturePage: settle (cap=${Math.min(opts.settleMs ?? 2000, remaining())}ms)`);
|
|
1165
1177
|
await page.waitForTimeout(Math.min(opts.settleMs ?? 2000, remaining()));
|
|
1166
1178
|
if (opts.scrollToLoad !== false && remaining() > 3000) {
|
|
1179
|
+
dlog(opts.side, opts.viewport, ` capturePage: scrollFullPage start (remaining=${remaining()}ms)`);
|
|
1167
1180
|
await Promise.race([
|
|
1168
1181
|
scrollFullPage(page).catch(() => {
|
|
1169
1182
|
return;
|
|
1170
1183
|
}),
|
|
1171
1184
|
new Promise((resolve) => setTimeout(resolve, Math.min(1e4, remaining())))
|
|
1172
1185
|
]);
|
|
1186
|
+
dlog(opts.side, opts.viewport, ` capturePage: scrollFullPage done (remaining=${remaining()}ms)`);
|
|
1173
1187
|
await page.waitForTimeout(Math.min(600, remaining()));
|
|
1174
1188
|
}
|
|
1175
1189
|
}
|
|
@@ -1179,23 +1193,29 @@ async function capturePage(page, opts) {
|
|
|
1179
1193
|
text: `[navigation-error] ${err.message}`
|
|
1180
1194
|
});
|
|
1181
1195
|
}
|
|
1196
|
+
dlog(opts.side, opts.viewport, ` capturePage: vitals evaluate (cap=${Math.min(5000, remaining())}ms)`);
|
|
1182
1197
|
vitals = await Promise.race([
|
|
1183
1198
|
page.evaluate(() => window.__parity_vitals).catch(() => null),
|
|
1184
1199
|
new Promise((resolve) => setTimeout(() => resolve(null), Math.min(5000, remaining())))
|
|
1185
1200
|
]) ?? null;
|
|
1186
1201
|
if (!opts.skipScreenshot) {
|
|
1202
|
+
dlog(opts.side, opts.viewport, ` capturePage: screenshot start (cap=${Math.min(15000, remaining())}ms)`);
|
|
1187
1203
|
await Promise.race([
|
|
1188
1204
|
page.screenshot({ path: opts.screenshotPath, fullPage: true, animations: "disabled" }).catch(() => {
|
|
1189
1205
|
return;
|
|
1190
1206
|
}),
|
|
1191
1207
|
new Promise((resolve) => setTimeout(resolve, Math.min(15000, remaining())))
|
|
1192
1208
|
]);
|
|
1209
|
+
dlog(opts.side, opts.viewport, " capturePage: screenshot done");
|
|
1193
1210
|
}
|
|
1211
|
+
dlog(opts.side, opts.viewport, ` capturePage: page.content() (cap=${Math.min(5000, remaining())}ms)`);
|
|
1194
1212
|
html = await Promise.race([
|
|
1195
1213
|
page.content().catch(() => ""),
|
|
1196
1214
|
new Promise((resolve) => setTimeout(() => resolve(""), Math.min(5000, remaining())))
|
|
1197
1215
|
]);
|
|
1216
|
+
dlog(opts.side, opts.viewport, ` capturePage: flushCollectors (cap=${Math.min(3000, remaining())}ms)`);
|
|
1198
1217
|
await flushCollectors(state, Math.min(3000, remaining()));
|
|
1218
|
+
dlog(opts.side, opts.viewport, ` capturePage: inner done total=${Date.now() - start}ms`);
|
|
1199
1219
|
return buildPartial();
|
|
1200
1220
|
};
|
|
1201
1221
|
const SAFETY_MARGIN_MS = 1e4;
|
|
@@ -4439,6 +4459,23 @@ async function callAnthropicTool(params) {
|
|
|
4439
4459
|
return null;
|
|
4440
4460
|
}
|
|
4441
4461
|
async function callOpenRouterTool(params) {
|
|
4462
|
+
const baseTokens = params.maxTokens ?? 2000;
|
|
4463
|
+
const totalBudget = params.timeoutMs ?? defaultTimeout(params);
|
|
4464
|
+
const start = Date.now();
|
|
4465
|
+
for (let attempt = 1;attempt <= 2; attempt++) {
|
|
4466
|
+
const isRetry = attempt > 1;
|
|
4467
|
+
const remaining = totalBudget - (Date.now() - start);
|
|
4468
|
+
if (remaining <= 2000 && isRetry) {
|
|
4469
|
+
return null;
|
|
4470
|
+
}
|
|
4471
|
+
const tokens = isRetry ? Math.max(baseTokens * 2, 4000) : baseTokens;
|
|
4472
|
+
const result = await openRouterToolOnce(params, tokens, isRetry, Math.max(remaining, 2000));
|
|
4473
|
+
if (result !== undefined)
|
|
4474
|
+
return result;
|
|
4475
|
+
}
|
|
4476
|
+
return null;
|
|
4477
|
+
}
|
|
4478
|
+
async function openRouterToolOnce(params, maxTokens, isRetry, attemptTimeoutMs) {
|
|
4442
4479
|
const apiKey = process.env.OPENROUTER_API_KEY;
|
|
4443
4480
|
if (!apiKey)
|
|
4444
4481
|
return null;
|
|
@@ -4451,8 +4488,7 @@ async function callOpenRouterTool(params) {
|
|
|
4451
4488
|
}
|
|
4452
4489
|
});
|
|
4453
4490
|
}
|
|
4454
|
-
const
|
|
4455
|
-
const { signal, clear } = makeTimeoutSignal(timeoutMs);
|
|
4491
|
+
const { signal, clear } = makeTimeoutSignal(attemptTimeoutMs);
|
|
4456
4492
|
try {
|
|
4457
4493
|
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
|
4458
4494
|
method: "POST",
|
|
@@ -4465,7 +4501,7 @@ async function callOpenRouterTool(params) {
|
|
|
4465
4501
|
},
|
|
4466
4502
|
body: JSON.stringify({
|
|
4467
4503
|
model: LLM_MODEL_OPENROUTER,
|
|
4468
|
-
max_tokens:
|
|
4504
|
+
max_tokens: maxTokens,
|
|
4469
4505
|
messages: [
|
|
4470
4506
|
{ role: "system", content: params.systemPrompt },
|
|
4471
4507
|
{ role: "user", content: userContent }
|
|
@@ -4486,16 +4522,29 @@ async function callOpenRouterTool(params) {
|
|
|
4486
4522
|
if (!response.ok) {
|
|
4487
4523
|
const txt = await response.text().catch(() => "");
|
|
4488
4524
|
console.error(`[llm-openrouter] HTTP ${response.status}: ${txt.slice(0, 200)}`);
|
|
4525
|
+
if (response.status >= 500 || response.status === 429)
|
|
4526
|
+
return;
|
|
4489
4527
|
return null;
|
|
4490
4528
|
}
|
|
4491
4529
|
const json = await response.json();
|
|
4492
4530
|
const toolCall = json.choices?.[0]?.message?.tool_calls?.[0];
|
|
4493
4531
|
if (toolCall?.function?.name === params.tool.name) {
|
|
4532
|
+
const raw = toolCall.function.arguments ?? "";
|
|
4494
4533
|
try {
|
|
4495
|
-
return JSON.parse(
|
|
4496
|
-
} catch
|
|
4497
|
-
|
|
4498
|
-
|
|
4534
|
+
return JSON.parse(raw);
|
|
4535
|
+
} catch {
|
|
4536
|
+
const repaired = tryRepairJson(raw);
|
|
4537
|
+
if (repaired) {
|
|
4538
|
+
try {
|
|
4539
|
+
return JSON.parse(repaired);
|
|
4540
|
+
} catch {}
|
|
4541
|
+
}
|
|
4542
|
+
if (isRetry) {
|
|
4543
|
+
console.error(`[llm-openrouter] failed to parse tool arguments after retry (raw len=${raw.length}, head=${JSON.stringify(raw.slice(0, 80))})`);
|
|
4544
|
+
return null;
|
|
4545
|
+
}
|
|
4546
|
+
console.error(`[llm-openrouter] parse error, retrying with more tokens (raw len=${raw.length}, head=${JSON.stringify(raw.slice(0, 80))})`);
|
|
4547
|
+
return;
|
|
4499
4548
|
}
|
|
4500
4549
|
}
|
|
4501
4550
|
const content = json.choices?.[0]?.message?.content;
|
|
@@ -4504,12 +4553,67 @@ async function callOpenRouterTool(params) {
|
|
|
4504
4553
|
return JSON.parse(content);
|
|
4505
4554
|
} catch {}
|
|
4506
4555
|
}
|
|
4556
|
+
return null;
|
|
4507
4557
|
} catch (err) {
|
|
4508
|
-
|
|
4558
|
+
const msg = err.message;
|
|
4559
|
+
console.error(`[llm-openrouter] failed: ${msg}`);
|
|
4560
|
+
if (!isRetry && (msg.includes("ECONN") || msg.includes("aborted") || msg.includes("fetch"))) {
|
|
4561
|
+
return;
|
|
4562
|
+
}
|
|
4563
|
+
return null;
|
|
4509
4564
|
} finally {
|
|
4510
4565
|
clear();
|
|
4511
4566
|
}
|
|
4512
|
-
|
|
4567
|
+
}
|
|
4568
|
+
function tryRepairJson(raw) {
|
|
4569
|
+
let s = raw.trim();
|
|
4570
|
+
if (!s)
|
|
4571
|
+
return null;
|
|
4572
|
+
const fence = s.match(/^```(?:json)?\s*([\s\S]*?)\s*```\s*$/);
|
|
4573
|
+
if (fence?.[1])
|
|
4574
|
+
s = fence[1].trim();
|
|
4575
|
+
if (!s.startsWith("{") && !s.startsWith("["))
|
|
4576
|
+
return null;
|
|
4577
|
+
let depthObj = 0;
|
|
4578
|
+
let depthArr = 0;
|
|
4579
|
+
let inString = false;
|
|
4580
|
+
let isEscaped = false;
|
|
4581
|
+
for (let i = 0;i < s.length; i++) {
|
|
4582
|
+
const ch = s[i];
|
|
4583
|
+
if (isEscaped) {
|
|
4584
|
+
isEscaped = false;
|
|
4585
|
+
continue;
|
|
4586
|
+
}
|
|
4587
|
+
if (ch === "\\") {
|
|
4588
|
+
isEscaped = true;
|
|
4589
|
+
continue;
|
|
4590
|
+
}
|
|
4591
|
+
if (ch === '"') {
|
|
4592
|
+
inString = !inString;
|
|
4593
|
+
continue;
|
|
4594
|
+
}
|
|
4595
|
+
if (inString)
|
|
4596
|
+
continue;
|
|
4597
|
+
if (ch === "{")
|
|
4598
|
+
depthObj++;
|
|
4599
|
+
else if (ch === "}")
|
|
4600
|
+
depthObj--;
|
|
4601
|
+
else if (ch === "[")
|
|
4602
|
+
depthArr++;
|
|
4603
|
+
else if (ch === "]")
|
|
4604
|
+
depthArr--;
|
|
4605
|
+
}
|
|
4606
|
+
if (inString)
|
|
4607
|
+
s += '"';
|
|
4608
|
+
while (depthArr > 0) {
|
|
4609
|
+
s += "]";
|
|
4610
|
+
depthArr--;
|
|
4611
|
+
}
|
|
4612
|
+
while (depthObj > 0) {
|
|
4613
|
+
s += "}";
|
|
4614
|
+
depthObj--;
|
|
4615
|
+
}
|
|
4616
|
+
return s;
|
|
4513
4617
|
}
|
|
4514
4618
|
async function callMessage(params) {
|
|
4515
4619
|
const provider = getProvider();
|
|
@@ -4794,15 +4898,55 @@ function compactHtmlForRecovery(html, maxChars = 12000) {
|
|
|
4794
4898
|
return html.slice(0, maxChars);
|
|
4795
4899
|
}
|
|
4796
4900
|
}
|
|
4901
|
+
var RECOVERY_SYSTEM_PROMPT = `
|
|
4902
|
+
Você ajuda a recuperar passos de teste E2E (Playwright) que falharam contra sites de e-commerce.
|
|
4903
|
+
|
|
4904
|
+
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.
|
|
4905
|
+
|
|
4906
|
+
REGRAS CRÍTICAS:
|
|
4907
|
+
|
|
4908
|
+
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.
|
|
4909
|
+
|
|
4910
|
+
2. **Se o step é 'go-checkout' ou 'go-checkout-retry':**
|
|
4911
|
+
- 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".
|
|
4912
|
+
- **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.
|
|
4913
|
+
- **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:
|
|
4914
|
+
a. Qualificação por **texto**: \`a[href*="checkout"]:has-text('Finalizar')\`, \`a[href*="checkout"]:has-text('Continuar')\`
|
|
4915
|
+
b. Qualificação por **scope**: \`[role='dialog'] a[href*="checkout"]\`, \`[class*='minicart' i] a[href*="checkout"]\`, \`.cart-drawer a[href*="checkout"]\`
|
|
4916
|
+
Selectors GENÉRICOS sem qualificador (apenas \`a[href*="checkout"]\` sozinho) casam o ícone do header e devem ser EVITADOS.
|
|
4917
|
+
- Se NENHUM elemento com texto/scope plausível existe no HTML, retorne selector="" para sinalizar incerteza honestamente.
|
|
4918
|
+
|
|
4919
|
+
3. **Se o step é 'shipping-calc-cart' ou 'shipping-calc-pdp':**
|
|
4920
|
+
- O alvo é um \`<input>\` (não button/link) visível com label/placeholder mencionando "CEP", "Frete", "Calcular", "Entrega", "Zip".
|
|
4921
|
+
- Prefira inputs cujo container/pai está visível (não escondido em accordion fechado).
|
|
4922
|
+
|
|
4923
|
+
4. **Seletores preferidos** (nesta ordem):
|
|
4924
|
+
- Text-based Playwright: \`button:has-text('Finalizar')\`, \`a:has-text('Continuar para pagamento')\`
|
|
4925
|
+
- data-attributes específicos: \`[data-checkout-button]\`, \`[data-cart-finalize]\`, \`[data-fs-cart-button]\`
|
|
4926
|
+
- ARIA: \`[aria-label*='finalizar' i]\`, \`[role='button'][aria-label*='checkout' i]\`
|
|
4927
|
+
- Classes específicas de plataforma: \`.vtex-minicart-2-x-checkoutButton\`, \`.fs-cart__checkout\`
|
|
4928
|
+
- Por último, escopo hierárquico curto: \`[role='dialog'] button.cta\`, \`.minicart-drawer button[type='submit']\`
|
|
4929
|
+
|
|
4930
|
+
5. **EVITE:**
|
|
4931
|
+
- IDs hash-gerados (\`#r-abc123\`)
|
|
4932
|
+
- Tailwind utility classes encadeadas (\`.flex.items-center.bg-blue\`)
|
|
4933
|
+
- Seletores genéricos sem qualificador (\`a[href*=...]\`, \`button[type=submit]\` sozinho)
|
|
4934
|
+
- Seletores que casam o ELEMENTO ERRADO em outra parte do DOM (ex: link de "checkout your reviews" no footer)
|
|
4935
|
+
- Seletores muito profundos (>4 níveis hierárquicos)
|
|
4936
|
+
|
|
4937
|
+
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.
|
|
4938
|
+
|
|
4939
|
+
Responda SEMPRE via tool_use suggest_recovery.
|
|
4940
|
+
`.trim();
|
|
4797
4941
|
async function suggestRecovery(input) {
|
|
4798
4942
|
const compacted = compactHtmlForRecovery(input.html);
|
|
4799
4943
|
const inp = await callTool({
|
|
4800
|
-
systemPrompt:
|
|
4944
|
+
systemPrompt: RECOVERY_SYSTEM_PROMPT,
|
|
4801
4945
|
userText: `Step: ${input.stepName}
|
|
4802
4946
|
Ação desejada: ${input.intendedAction}
|
|
4803
|
-
${input.alreadyTried?.length ? `Já tentei: ${input.alreadyTried.join(", ")}
|
|
4947
|
+
${input.alreadyTried?.length ? `Já tentei (não repita estes): ${input.alreadyTried.join(", ")}
|
|
4804
4948
|
` : ""}
|
|
4805
|
-
HTML compactado:
|
|
4949
|
+
HTML compactado da página NESTE momento:
|
|
4806
4950
|
\`\`\`html
|
|
4807
4951
|
${compacted}
|
|
4808
4952
|
\`\`\``,
|
|
@@ -4813,9 +4957,9 @@ ${compacted}
|
|
|
4813
4957
|
inputSchema: RECOVER_STEP_INPUT_SCHEMA
|
|
4814
4958
|
}
|
|
4815
4959
|
});
|
|
4816
|
-
if (inp?.selector && inp.action) {
|
|
4960
|
+
if (inp?.selector?.trim() && inp.action) {
|
|
4817
4961
|
return {
|
|
4818
|
-
selector: inp.selector,
|
|
4962
|
+
selector: inp.selector.trim(),
|
|
4819
4963
|
action: inp.action,
|
|
4820
4964
|
value: inp.value,
|
|
4821
4965
|
reasoning: inp.reasoning
|
|
@@ -4835,7 +4979,13 @@ var SelectorKey = z2.enum([
|
|
|
4835
4979
|
"minicartTrigger",
|
|
4836
4980
|
"cepInputPdp",
|
|
4837
4981
|
"cepInputCart",
|
|
4838
|
-
"checkoutButton"
|
|
4982
|
+
"checkoutButton",
|
|
4983
|
+
"sizeSwatch",
|
|
4984
|
+
"colorSwatch",
|
|
4985
|
+
"variantRow",
|
|
4986
|
+
"quantityIncrement",
|
|
4987
|
+
"quantityInput",
|
|
4988
|
+
"minicartCount"
|
|
4839
4989
|
]);
|
|
4840
4990
|
var SelectorEntry = z2.object({
|
|
4841
4991
|
selector: z2.string(),
|
|
@@ -5029,8 +5179,80 @@ var DEFAULT_SELECTORS = {
|
|
|
5029
5179
|
"button:has-text('Finalizar compra')",
|
|
5030
5180
|
"a:has-text('Ir para o checkout')",
|
|
5031
5181
|
"button:has-text('Ir para o checkout')",
|
|
5182
|
+
"[role='dialog'] a:has-text('Finalizar')",
|
|
5183
|
+
"[role='dialog'] button:has-text('Finalizar')",
|
|
5184
|
+
"[data-minicart] a:has-text('Finalizar')",
|
|
5185
|
+
"[data-minicart] button:has-text('Finalizar')",
|
|
5186
|
+
"[data-cart-drawer] a:has-text('Finalizar')",
|
|
5187
|
+
"[data-cart-drawer] button:has-text('Finalizar')",
|
|
5032
5188
|
"a:has-text('Checkout')",
|
|
5033
|
-
"[data-checkout-button]"
|
|
5189
|
+
"[data-checkout-button]",
|
|
5190
|
+
"a:text-is('Finalizar')",
|
|
5191
|
+
"button:text-is('Finalizar')"
|
|
5192
|
+
],
|
|
5193
|
+
sizeSwatch: [
|
|
5194
|
+
"[data-size]:not([disabled]):not([aria-disabled='true']):not(.unavailable):not(.sold-out)",
|
|
5195
|
+
"[data-variant-size]:not([disabled]):not([aria-disabled='true']):not(.unavailable)",
|
|
5196
|
+
"button[aria-label*='tamanho' i]:not([aria-disabled='true']):not([disabled])",
|
|
5197
|
+
"button[aria-label*='size' i]:not([aria-disabled='true']):not([disabled])",
|
|
5198
|
+
"[data-testid='size-selector'] button:not([disabled]):not(.unavailable)",
|
|
5199
|
+
".size-selector button:not([disabled]):not(.unavailable):not(.sold-out)",
|
|
5200
|
+
"[class*='size'] button:not([disabled]):not(.unavailable)",
|
|
5201
|
+
"label:has(input[name*='size' i]:not([disabled])) "
|
|
5202
|
+
],
|
|
5203
|
+
colorSwatch: [
|
|
5204
|
+
"[data-color]:not([disabled]):not([aria-disabled='true']):not(.unavailable):not(.sold-out)",
|
|
5205
|
+
"[data-variant-color]:not([disabled]):not([aria-disabled='true']):not(.unavailable)",
|
|
5206
|
+
"button[aria-label*='cor' i]:not([aria-disabled='true']):not([disabled])",
|
|
5207
|
+
"button[aria-label*='color' i]:not([aria-disabled='true']):not([disabled])",
|
|
5208
|
+
"[data-testid='color-selector'] button:not([disabled]):not(.unavailable)",
|
|
5209
|
+
".color-swatch:not(.unavailable):not(.sold-out)",
|
|
5210
|
+
"[class*='color'] button:not([disabled]):not(.unavailable)"
|
|
5211
|
+
],
|
|
5212
|
+
variantRow: [
|
|
5213
|
+
"[data-sku-row]",
|
|
5214
|
+
"[data-variant-row]",
|
|
5215
|
+
"tr[data-variant]",
|
|
5216
|
+
"tr:has([aria-label*='quantidade' i])",
|
|
5217
|
+
"tbody tr:has(input[type='number'])"
|
|
5218
|
+
],
|
|
5219
|
+
quantityIncrement: [
|
|
5220
|
+
"[data-qty-plus]",
|
|
5221
|
+
"[data-quantity-plus]",
|
|
5222
|
+
"button[aria-label*='aumentar' i]",
|
|
5223
|
+
"button[aria-label*='increase' i]",
|
|
5224
|
+
"button[aria-label*='increment' i]",
|
|
5225
|
+
"button:has-text('+')",
|
|
5226
|
+
"[class*='qty'] button:has-text('+')"
|
|
5227
|
+
],
|
|
5228
|
+
quantityInput: [
|
|
5229
|
+
"input[type='number'][min='0'][value='0']",
|
|
5230
|
+
"input[id*='quantity-input' i]",
|
|
5231
|
+
"input[name*='qty' i]",
|
|
5232
|
+
"input[name*='quantity' i]",
|
|
5233
|
+
"input[name*='quantidade' i]",
|
|
5234
|
+
"input[aria-label*='quantidade' i]",
|
|
5235
|
+
"[data-qty-input]",
|
|
5236
|
+
"input[type='number'][min='0']",
|
|
5237
|
+
"input[type='number'][min='1']"
|
|
5238
|
+
],
|
|
5239
|
+
minicartCount: [
|
|
5240
|
+
"[data-minicart-count]",
|
|
5241
|
+
"[data-cart-count]",
|
|
5242
|
+
".cart-count",
|
|
5243
|
+
".minicart__count",
|
|
5244
|
+
"[class*='cart-count']",
|
|
5245
|
+
"[class*='CartCount']",
|
|
5246
|
+
"[aria-label*='itens' i][role='status']",
|
|
5247
|
+
"header [data-minicart-trigger] [class*='badge']",
|
|
5248
|
+
"header [aria-label*='cart' i] [class*='count']",
|
|
5249
|
+
"header [aria-label*='sacola' i] [class*='badge']",
|
|
5250
|
+
"header [aria-label*='sacola' i] [class*='count']",
|
|
5251
|
+
"header a[href*='/cart'] [class*='count']",
|
|
5252
|
+
"header a[href*='/checkout'] [class*='count']",
|
|
5253
|
+
"[data-fs-cart-icon] + [class*='count']",
|
|
5254
|
+
"[class*='Minicart'] [class*='count']",
|
|
5255
|
+
"[class*='minicart'] [class*='count']"
|
|
5034
5256
|
]
|
|
5035
5257
|
};
|
|
5036
5258
|
function selectorsFor(key, ctxOrRc = {}) {
|
|
@@ -5055,7 +5277,51 @@ function isResolutionContext(v) {
|
|
|
5055
5277
|
}
|
|
5056
5278
|
|
|
5057
5279
|
// src/engine/flows.ts
|
|
5058
|
-
var PURCHASE_JOURNEY_TOTAL_STEPS =
|
|
5280
|
+
var PURCHASE_JOURNEY_TOTAL_STEPS = 9;
|
|
5281
|
+
var DEBUG_PARITY2 = process.env.DEBUG_PARITY === "1" || process.env.DEBUG_PARITY === "true";
|
|
5282
|
+
var DEBUG_START2 = Date.now();
|
|
5283
|
+
function dlog2(ctx, msg) {
|
|
5284
|
+
if (!DEBUG_PARITY2)
|
|
5285
|
+
return;
|
|
5286
|
+
const elapsed = ((Date.now() - DEBUG_START2) / 1000).toFixed(1);
|
|
5287
|
+
process.stderr.write(`[+${elapsed}s ${ctx.viewport}/${ctx.side}] ${msg}
|
|
5288
|
+
`);
|
|
5289
|
+
}
|
|
5290
|
+
function withCap(p, capMs, fallback) {
|
|
5291
|
+
return Promise.race([
|
|
5292
|
+
p.catch(() => fallback),
|
|
5293
|
+
new Promise((resolve) => setTimeout(() => resolve(fallback), capMs))
|
|
5294
|
+
]);
|
|
5295
|
+
}
|
|
5296
|
+
var VARIANT_REQUIRED_TEXT_PATTERNS = [
|
|
5297
|
+
/selecione um produto/i,
|
|
5298
|
+
/selecione um tamanho/i,
|
|
5299
|
+
/selecione uma cor/i,
|
|
5300
|
+
/selecione uma op[cç][aã]o/i,
|
|
5301
|
+
/select a size/i,
|
|
5302
|
+
/select a color/i,
|
|
5303
|
+
/select an option/i,
|
|
5304
|
+
/choose an option/i,
|
|
5305
|
+
/please select/i,
|
|
5306
|
+
/select size/i,
|
|
5307
|
+
/select color/i
|
|
5308
|
+
];
|
|
5309
|
+
var ADD_TO_CART_ERROR_PATTERNS = [
|
|
5310
|
+
...VARIANT_REQUIRED_TEXT_PATTERNS,
|
|
5311
|
+
/estoque esgotado/i,
|
|
5312
|
+
/out of stock/i,
|
|
5313
|
+
/indispon[ií]vel/i,
|
|
5314
|
+
/unavailable/i
|
|
5315
|
+
];
|
|
5316
|
+
var ADD_TO_CART_SUCCESS_PATTERNS = [
|
|
5317
|
+
/produto adicionado/i,
|
|
5318
|
+
/adicionado ao carrinho/i,
|
|
5319
|
+
/adicionado [aà]\s+sacola/i,
|
|
5320
|
+
/added to cart/i,
|
|
5321
|
+
/added to bag/i,
|
|
5322
|
+
/item added/i,
|
|
5323
|
+
/successfully added/i
|
|
5324
|
+
];
|
|
5059
5325
|
function selFor(ctx, key) {
|
|
5060
5326
|
return selectorsFor(key, { rc: ctx.rc, learned: ctx.learned, platform: ctx.platform });
|
|
5061
5327
|
}
|
|
@@ -5063,7 +5329,7 @@ var FLOW_DEADLINE_MS = {
|
|
|
5063
5329
|
homepage: 90000,
|
|
5064
5330
|
plp: 180000,
|
|
5065
5331
|
pdp: 240000,
|
|
5066
|
-
"purchase-journey":
|
|
5332
|
+
"purchase-journey": 420000
|
|
5067
5333
|
};
|
|
5068
5334
|
async function runFlow(flow, ctx) {
|
|
5069
5335
|
const start = Date.now();
|
|
@@ -5094,16 +5360,23 @@ async function runFlow(flow, ctx) {
|
|
|
5094
5360
|
resolve(finalize(flow, ctx, [], [
|
|
5095
5361
|
{
|
|
5096
5362
|
step: 0,
|
|
5097
|
-
name: "
|
|
5363
|
+
name: "flow-timeout",
|
|
5098
5364
|
side: ctx.side,
|
|
5099
5365
|
viewport: ctx.viewport,
|
|
5100
5366
|
status: "failed",
|
|
5101
5367
|
durationMs: deadlineMs,
|
|
5102
5368
|
screenshotPath: "",
|
|
5103
|
-
actionDescription: `[flow-timeout] flow "${flow}" excedeu ${deadlineMs}ms — captura abortada pela safety net externa, ${pages.length} page(s) fechada(s) para liberar o contexto
|
|
5369
|
+
actionDescription: `[flow-timeout] flow "${flow}" excedeu ${deadlineMs}ms — captura abortada pela safety net externa, ${pages.length} page(s) fechada(s) para liberar o contexto.`
|
|
5104
5370
|
}
|
|
5105
5371
|
], start));
|
|
5106
|
-
|
|
5372
|
+
const CLOSE_CAP_MS = 5000;
|
|
5373
|
+
const cappedClose = (p) => Promise.race([
|
|
5374
|
+
p.close().catch(() => {
|
|
5375
|
+
return;
|
|
5376
|
+
}),
|
|
5377
|
+
new Promise((resolveClose) => setTimeout(resolveClose, CLOSE_CAP_MS))
|
|
5378
|
+
]);
|
|
5379
|
+
cleanup = Promise.allSettled(pages.map(cappedClose));
|
|
5107
5380
|
}, deadlineMs);
|
|
5108
5381
|
});
|
|
5109
5382
|
try {
|
|
@@ -5209,7 +5482,7 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5209
5482
|
const pages = [];
|
|
5210
5483
|
const steps = [];
|
|
5211
5484
|
const page = await ctx.ctx.newPage();
|
|
5212
|
-
|
|
5485
|
+
const budget = { remaining: ctx.recoveryBudget ?? 5 };
|
|
5213
5486
|
const total = PURCHASE_JOURNEY_TOTAL_STEPS;
|
|
5214
5487
|
const reportStart = (idx, name) => {
|
|
5215
5488
|
ctx.onStep?.({ phase: "start", name, index: idx, total });
|
|
@@ -5274,9 +5547,35 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5274
5547
|
steps[steps.length - 1].actionDescription = `Navegou pra categoria \`${plpHit.url}\` (via \`${plpHit.selector}\`)`;
|
|
5275
5548
|
steps[steps.length - 1].beforeUrl = ctx.baseUrl;
|
|
5276
5549
|
reportStart(3, "enter-pdp");
|
|
5277
|
-
|
|
5550
|
+
let pdpHit = await findProductUrl(page, ctx);
|
|
5551
|
+
let pdpRecoveredByLlm = false;
|
|
5552
|
+
if (!pdpHit && budget.remaining > 0) {
|
|
5553
|
+
const html = await page.content().catch(() => "");
|
|
5554
|
+
if (html) {
|
|
5555
|
+
const suggestion = await suggestRecovery({
|
|
5556
|
+
stepName: "enter-pdp",
|
|
5557
|
+
intendedAction: "Encontrar um link <a> que leve para a página de detalhes (PDP) de algum produto listado na PLP atual",
|
|
5558
|
+
html,
|
|
5559
|
+
alreadyTried: selFor(ctx, "productCard")
|
|
5560
|
+
});
|
|
5561
|
+
if (suggestion) {
|
|
5562
|
+
budget.remaining--;
|
|
5563
|
+
try {
|
|
5564
|
+
const el = page.locator(suggestion.selector).first();
|
|
5565
|
+
if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
5566
|
+
const href = await el.getAttribute("href");
|
|
5567
|
+
if (href) {
|
|
5568
|
+
const abs = new URL(href, page.url()).toString();
|
|
5569
|
+
pdpHit = { url: abs, selector: suggestion.selector };
|
|
5570
|
+
pdpRecoveredByLlm = true;
|
|
5571
|
+
}
|
|
5572
|
+
}
|
|
5573
|
+
} catch {}
|
|
5574
|
+
}
|
|
5575
|
+
}
|
|
5576
|
+
}
|
|
5278
5577
|
if (!pdpHit) {
|
|
5279
|
-
steps.push(makeSkipStep(3, "enter-pdp", ctx, "no product card found"));
|
|
5578
|
+
steps.push(makeSkipStep(3, "enter-pdp", ctx, "no product card found (recovery exhausted)"));
|
|
5280
5579
|
reportEnd(3, "enter-pdp", "skipped", 0, "no product card found");
|
|
5281
5580
|
return { pages, steps };
|
|
5282
5581
|
}
|
|
@@ -5299,272 +5598,466 @@ async function flowPurchaseJourney(ctx) {
|
|
|
5299
5598
|
url: pdpCap.finalUrl,
|
|
5300
5599
|
screenshotPath: pdpCap.screenshotPath,
|
|
5301
5600
|
selectorKey: "productCard",
|
|
5302
|
-
usedSelector: pdpHit.selector
|
|
5601
|
+
usedSelector: pdpHit.selector,
|
|
5602
|
+
recoveredByLlm: pdpRecoveredByLlm || undefined
|
|
5303
5603
|
});
|
|
5304
5604
|
reportEnd(3, "enter-pdp", step3Status, Date.now() - t3);
|
|
5305
|
-
steps[steps.length - 1].actionDescription = `Abriu PDP \`${pdpHit.url}\` (via \`${pdpHit.selector}
|
|
5605
|
+
steps[steps.length - 1].actionDescription = `Abriu PDP \`${pdpHit.url}\` (via \`${pdpHit.selector}\`${pdpRecoveredByLlm ? " — recovery LLM" : ""})`;
|
|
5306
5606
|
steps[steps.length - 1].beforeUrl = plpHit.url;
|
|
5307
|
-
|
|
5607
|
+
const expectedProductTitle = await extractProductTitle(page);
|
|
5608
|
+
dlog2(ctx, `step 3 enter-pdp: extracted product title → ${expectedProductTitle ? `"${expectedProductTitle.slice(0, 60)}"` : "null"}`);
|
|
5609
|
+
if (expectedProductTitle) {
|
|
5610
|
+
steps[steps.length - 1].detail = {
|
|
5611
|
+
...steps[steps.length - 1].detail ?? {},
|
|
5612
|
+
productTitle: expectedProductTitle
|
|
5613
|
+
};
|
|
5614
|
+
}
|
|
5615
|
+
reportStart(4, "select-variant");
|
|
5616
|
+
const t4 = Date.now();
|
|
5617
|
+
const beforeUrl4 = page.url();
|
|
5618
|
+
const spBefore4 = screenshotPath(ctx, "pj-4-select-variant-before");
|
|
5619
|
+
await page.screenshot({ path: spBefore4, fullPage: false }).catch(() => {
|
|
5620
|
+
return;
|
|
5621
|
+
});
|
|
5622
|
+
const variantResult = await selectVariant(page, ctx);
|
|
5623
|
+
let variantLlmAction = null;
|
|
5624
|
+
if (variantResult.actions.length === 0 && variantResult.variantRequired && budget.remaining > 0) {
|
|
5625
|
+
variantLlmAction = await attemptStepAction({
|
|
5626
|
+
page,
|
|
5627
|
+
ctx,
|
|
5628
|
+
stepName: "select-variant",
|
|
5629
|
+
intendedAction: "Selecionar uma variante disponível (tamanho, cor, sabor) ou clicar no botão '+' para incrementar a quantidade da primeira variante listada na PDP. Não clique no botão de comprar.",
|
|
5630
|
+
selectorKey: "quantityIncrement",
|
|
5631
|
+
action: "click",
|
|
5632
|
+
recoveryBudget: budget
|
|
5633
|
+
});
|
|
5634
|
+
}
|
|
5635
|
+
const sp4 = screenshotPath(ctx, "pj-4-select-variant");
|
|
5636
|
+
await page.screenshot({ path: sp4, fullPage: false }).catch(() => {
|
|
5637
|
+
return;
|
|
5638
|
+
});
|
|
5639
|
+
if (variantResult.actions.length > 0 || variantLlmAction?.performed) {
|
|
5640
|
+
const llmDesc = variantLlmAction?.performed ? `Recovery LLM: ${variantLlmAction.action} em \`${variantLlmAction.selector}\`` : null;
|
|
5641
|
+
const desc = variantResult.actions.length > 0 ? variantResult.actions.join("; ") : llmDesc ?? "(variante selecionada)";
|
|
5642
|
+
steps.push({
|
|
5643
|
+
step: 4,
|
|
5644
|
+
name: "select-variant",
|
|
5645
|
+
side: ctx.side,
|
|
5646
|
+
viewport: ctx.viewport,
|
|
5647
|
+
status: "ok",
|
|
5648
|
+
durationMs: Date.now() - t4,
|
|
5649
|
+
url: page.url(),
|
|
5650
|
+
screenshotPath: sp4,
|
|
5651
|
+
screenshotBeforePath: spBefore4,
|
|
5652
|
+
beforeUrl: beforeUrl4,
|
|
5653
|
+
actionDescription: llmDesc && variantResult.actions.length > 0 ? `${desc}; ${llmDesc}` : desc,
|
|
5654
|
+
selectorKey: variantLlmAction?.performed ? "quantityIncrement" : variantResult.primarySelectorKey,
|
|
5655
|
+
usedSelector: variantLlmAction?.performed ? variantLlmAction.selector : variantResult.primarySelector,
|
|
5656
|
+
recoveredByLlm: variantLlmAction?.recoveredByLlm || undefined,
|
|
5657
|
+
detail: { actions: variantResult.actions, llmRecovery: !!variantLlmAction?.performed }
|
|
5658
|
+
});
|
|
5659
|
+
reportEnd(4, "select-variant", "ok", Date.now() - t4);
|
|
5660
|
+
} else {
|
|
5661
|
+
const skipNote = variantResult.variantRequired ? "variant required by store but no selectors matched (LLM recovery also failed)" : "no variant selectors found on PDP (assuming single-SKU product)";
|
|
5662
|
+
steps.push(makeSkipStep(4, "select-variant", ctx, skipNote));
|
|
5663
|
+
reportEnd(4, "select-variant", "skipped", 0, skipNote);
|
|
5664
|
+
}
|
|
5665
|
+
reportStart(5, "shipping-calc-pdp");
|
|
5308
5666
|
let cepInputPdp = await firstVisible(page, selFor(ctx, "cepInputPdp"));
|
|
5309
|
-
let
|
|
5310
|
-
if (!cepInputPdp &&
|
|
5311
|
-
const
|
|
5312
|
-
if (
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
5667
|
+
let cepPdpRecoveredByLlm = false;
|
|
5668
|
+
if (!cepInputPdp && budget.remaining > 0) {
|
|
5669
|
+
const html = await page.content().catch(() => "");
|
|
5670
|
+
if (html) {
|
|
5671
|
+
const suggestion = await suggestRecovery({
|
|
5672
|
+
stepName: "shipping-calc-pdp",
|
|
5673
|
+
intendedAction: "Encontre o <input> de CEP (código postal de ENDEREÇO de entrega) na PDP, usado pra CALCULAR FRETE — placeholder normalmente é 'CEP', 'Digite seu CEP', 'Cód. postal', 'Postal code', aria-label='CEP' ou name='zip'/'cep'/'postal'. **NÃO** retorne o input de CUPOM de desconto / código promocional / 'Digite o cupom' / 'Insira código' — esses são pra desconto, não pra frete.",
|
|
5674
|
+
html,
|
|
5675
|
+
alreadyTried: selFor(ctx, "cepInputPdp")
|
|
5676
|
+
});
|
|
5677
|
+
if (suggestion) {
|
|
5678
|
+
budget.remaining--;
|
|
5679
|
+
const el = page.locator(suggestion.selector).first();
|
|
5680
|
+
if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
5681
|
+
cepInputPdp = suggestion.selector;
|
|
5682
|
+
cepPdpRecoveredByLlm = true;
|
|
5683
|
+
}
|
|
5684
|
+
}
|
|
5316
5685
|
}
|
|
5317
5686
|
}
|
|
5318
5687
|
if (cepInputPdp) {
|
|
5319
|
-
const
|
|
5320
|
-
const
|
|
5321
|
-
const
|
|
5322
|
-
await page.screenshot({ path:
|
|
5688
|
+
const t5 = Date.now();
|
|
5689
|
+
const beforeUrl5 = page.url();
|
|
5690
|
+
const spBefore5 = screenshotPath(ctx, "pj-5-shipping-pdp-before");
|
|
5691
|
+
await page.screenshot({ path: spBefore5, fullPage: false }).catch(() => {
|
|
5323
5692
|
return;
|
|
5324
5693
|
});
|
|
5325
5694
|
const ok = await fillCep(page, cepInputPdp, ctx.rc.cep);
|
|
5326
|
-
const sp = screenshotPath(ctx, "pj-
|
|
5695
|
+
const sp = screenshotPath(ctx, "pj-5-shipping-pdp");
|
|
5327
5696
|
await page.screenshot({ path: sp, fullPage: false }).catch(() => {
|
|
5328
5697
|
return;
|
|
5329
5698
|
});
|
|
5330
|
-
const
|
|
5699
|
+
const step5Status = ok ? "ok" : "failed";
|
|
5331
5700
|
steps.push({
|
|
5332
|
-
step:
|
|
5701
|
+
step: 5,
|
|
5333
5702
|
name: "shipping-calc-pdp",
|
|
5334
5703
|
side: ctx.side,
|
|
5335
5704
|
viewport: ctx.viewport,
|
|
5336
|
-
status:
|
|
5337
|
-
durationMs: Date.now() -
|
|
5705
|
+
status: step5Status,
|
|
5706
|
+
durationMs: Date.now() - t5,
|
|
5338
5707
|
url: page.url(),
|
|
5339
5708
|
screenshotPath: sp,
|
|
5340
|
-
screenshotBeforePath:
|
|
5341
|
-
beforeUrl:
|
|
5342
|
-
actionDescription: `Preencheu CEP '${ctx.rc.cep}' no input \`${cepInputPdp}
|
|
5709
|
+
screenshotBeforePath: spBefore5,
|
|
5710
|
+
beforeUrl: beforeUrl5,
|
|
5711
|
+
actionDescription: `Preencheu CEP '${ctx.rc.cep}' no input \`${cepInputPdp}\`${cepPdpRecoveredByLlm ? " (via recovery LLM)" : ""}`,
|
|
5343
5712
|
detail: { cepUsed: ctx.rc.cep },
|
|
5344
5713
|
selectorKey: "cepInputPdp",
|
|
5345
5714
|
usedSelector: cepInputPdp,
|
|
5346
|
-
recoveredByLlm:
|
|
5715
|
+
recoveredByLlm: cepPdpRecoveredByLlm || undefined
|
|
5347
5716
|
});
|
|
5348
|
-
reportEnd(
|
|
5717
|
+
reportEnd(5, "shipping-calc-pdp", step5Status, Date.now() - t5);
|
|
5349
5718
|
} else {
|
|
5350
|
-
steps.push(makeSkipStep(
|
|
5351
|
-
reportEnd(
|
|
5719
|
+
steps.push(makeSkipStep(5, "shipping-calc-pdp", ctx, "no CEP input on PDP (recovery exhausted)"));
|
|
5720
|
+
reportEnd(5, "shipping-calc-pdp", "skipped", 0, "no CEP input on PDP");
|
|
5352
5721
|
}
|
|
5353
|
-
reportStart(
|
|
5722
|
+
reportStart(6, "add-to-cart");
|
|
5354
5723
|
let buyHit = await firstVisibleLocator(page, selFor(ctx, "buyButton"));
|
|
5355
5724
|
let buyRecovered = false;
|
|
5356
|
-
if (!buyHit &&
|
|
5725
|
+
if (!buyHit && budget.remaining > 0) {
|
|
5357
5726
|
const recovery = await attemptRecovery(page, ctx, "add-to-cart", "Clicar no botão de comprar/adicionar ao carrinho", selFor(ctx, "buyButton"));
|
|
5358
5727
|
if (recovery) {
|
|
5359
5728
|
buyHit = recovery;
|
|
5360
5729
|
buyRecovered = true;
|
|
5361
|
-
|
|
5730
|
+
budget.remaining--;
|
|
5362
5731
|
}
|
|
5363
5732
|
}
|
|
5364
5733
|
if (!buyHit) {
|
|
5365
|
-
steps.push(makeSkipStep(
|
|
5366
|
-
reportEnd(
|
|
5734
|
+
steps.push(makeSkipStep(6, "add-to-cart", ctx, "no buy button found (recovery exhausted)"));
|
|
5735
|
+
reportEnd(6, "add-to-cart", "skipped", 0, "no buy button found");
|
|
5367
5736
|
return { pages, steps };
|
|
5368
5737
|
}
|
|
5369
|
-
const
|
|
5370
|
-
const
|
|
5371
|
-
const
|
|
5372
|
-
await page.screenshot({ path:
|
|
5738
|
+
const t6 = Date.now();
|
|
5739
|
+
const beforeUrl6 = page.url();
|
|
5740
|
+
const spBefore6 = screenshotPath(ctx, "pj-6-add-cart-before");
|
|
5741
|
+
await page.screenshot({ path: spBefore6, fullPage: false }).catch(() => {
|
|
5373
5742
|
return;
|
|
5374
5743
|
});
|
|
5744
|
+
const cartCountBefore = await readCartCount(page, ctx);
|
|
5375
5745
|
const buyText = await buyHit.locator.innerText().catch(() => "");
|
|
5376
5746
|
await buyHit.locator.click({ timeout: 5000 }).catch(() => {
|
|
5377
5747
|
return;
|
|
5378
5748
|
});
|
|
5379
|
-
await page
|
|
5380
|
-
|
|
5381
|
-
|
|
5749
|
+
let validation = await validateAddToCart(page, ctx, cartCountBefore, beforeUrl6);
|
|
5750
|
+
let variantRetryNote;
|
|
5751
|
+
if (validation.status === "failed" && validation.errorText && VARIANT_REQUIRED_TEXT_PATTERNS.some((re) => re.test(validation.errorText))) {
|
|
5752
|
+
const retryHit = await findAndIncrementZeroQtyInput(page, ctx);
|
|
5753
|
+
if (retryHit) {
|
|
5754
|
+
variantRetryNote = `Retry: ${retryHit.description}`;
|
|
5755
|
+
} else if (budget.remaining > 0) {
|
|
5756
|
+
const llmRetry = await attemptStepAction({
|
|
5757
|
+
page,
|
|
5758
|
+
ctx,
|
|
5759
|
+
stepName: "add-to-cart-retry-variant",
|
|
5760
|
+
intendedAction: "A loja rejeitou o add-to-cart com a mensagem 'Selecione um produto/tamanho/cor'. Encontre o picker de variante visível e selecione UMA opção (clicar num swatch, em '+' ao lado de uma quantidade '0', em um botão de tamanho). Não clique no botão de comprar.",
|
|
5761
|
+
selectorKey: "quantityIncrement",
|
|
5762
|
+
action: "click",
|
|
5763
|
+
recoveryBudget: budget
|
|
5764
|
+
});
|
|
5765
|
+
if (llmRetry.performed) {
|
|
5766
|
+
variantRetryNote = `Retry via LLM: ${llmRetry.action} em \`${llmRetry.selector}\``;
|
|
5767
|
+
}
|
|
5768
|
+
}
|
|
5769
|
+
if (variantRetryNote) {
|
|
5770
|
+
await page.waitForTimeout(500);
|
|
5771
|
+
await buyHit.locator.click({ timeout: 5000 }).catch(() => {
|
|
5772
|
+
return;
|
|
5773
|
+
});
|
|
5774
|
+
validation = await validateAddToCart(page, ctx, cartCountBefore, beforeUrl6);
|
|
5775
|
+
}
|
|
5776
|
+
}
|
|
5777
|
+
const sp6 = screenshotPath(ctx, "pj-6-add-cart");
|
|
5778
|
+
await page.screenshot({ path: sp6, fullPage: false }).catch(() => {
|
|
5382
5779
|
return;
|
|
5383
5780
|
});
|
|
5781
|
+
const buyActionDesc = `Clicou no botão${buyText ? ` '${buyText.slice(0, 40).trim()}'` : ""} (\`${buyHit.selector}\`)${buyRecovered ? " — selector veio de recovery LLM" : ""}`;
|
|
5782
|
+
const fullActionDesc = variantRetryNote ? `${buyActionDesc} — ${variantRetryNote} — ${validation.note}` : `${buyActionDesc} — ${validation.note}`;
|
|
5384
5783
|
steps.push({
|
|
5385
|
-
step:
|
|
5784
|
+
step: 6,
|
|
5386
5785
|
name: "add-to-cart",
|
|
5387
5786
|
side: ctx.side,
|
|
5388
5787
|
viewport: ctx.viewport,
|
|
5389
|
-
status:
|
|
5390
|
-
durationMs: Date.now() -
|
|
5788
|
+
status: validation.status,
|
|
5789
|
+
durationMs: Date.now() - t6,
|
|
5391
5790
|
url: page.url(),
|
|
5392
|
-
screenshotPath:
|
|
5393
|
-
screenshotBeforePath:
|
|
5394
|
-
beforeUrl:
|
|
5395
|
-
actionDescription:
|
|
5791
|
+
screenshotPath: sp6,
|
|
5792
|
+
screenshotBeforePath: spBefore6,
|
|
5793
|
+
beforeUrl: beforeUrl6,
|
|
5794
|
+
actionDescription: fullActionDesc,
|
|
5396
5795
|
selectorKey: "buyButton",
|
|
5397
5796
|
usedSelector: buyHit.selector,
|
|
5398
|
-
recoveredByLlm: buyRecovered || undefined
|
|
5797
|
+
recoveredByLlm: buyRecovered || undefined,
|
|
5798
|
+
note: validation.status === "ok" ? undefined : validation.note,
|
|
5799
|
+
detail: { signal: validation.signal, errorText: validation.errorText, variantRetry: variantRetryNote }
|
|
5399
5800
|
});
|
|
5400
|
-
reportEnd(
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
5801
|
+
reportEnd(6, "add-to-cart", validation.status, Date.now() - t6, validation.status === "ok" ? undefined : validation.note);
|
|
5802
|
+
if (validation.status === "failed") {
|
|
5803
|
+
return { pages, steps };
|
|
5804
|
+
}
|
|
5805
|
+
reportStart(7, "open-minicart");
|
|
5806
|
+
const t7 = Date.now();
|
|
5807
|
+
const beforeUrl7 = page.url();
|
|
5808
|
+
const spBefore7 = screenshotPath(ctx, "pj-7-minicart-before");
|
|
5809
|
+
await page.screenshot({ path: spBefore7, fullPage: false }).catch(() => {
|
|
5406
5810
|
return;
|
|
5407
5811
|
});
|
|
5408
5812
|
let miniHit = await firstVisibleLocator(page, selFor(ctx, "minicartTrigger"));
|
|
5409
5813
|
let miniRecovered = false;
|
|
5410
|
-
if (!miniHit &&
|
|
5814
|
+
if (!miniHit && budget.remaining > 0) {
|
|
5411
5815
|
const recovery = await attemptRecovery(page, ctx, "open-minicart", "Abrir o minicart/drawer do carrinho", selFor(ctx, "minicartTrigger"));
|
|
5412
5816
|
if (recovery) {
|
|
5413
5817
|
miniHit = recovery;
|
|
5414
5818
|
miniRecovered = true;
|
|
5415
|
-
|
|
5819
|
+
budget.remaining--;
|
|
5416
5820
|
}
|
|
5417
5821
|
}
|
|
5822
|
+
let cartOpenMethod = "failed";
|
|
5418
5823
|
let miniText = "";
|
|
5419
5824
|
if (miniHit) {
|
|
5420
5825
|
miniText = await miniHit.locator.innerText().catch(() => "");
|
|
5421
|
-
await
|
|
5422
|
-
|
|
5423
|
-
|
|
5424
|
-
await page
|
|
5826
|
+
const openResult = await openMinicart(page, miniHit, ctx, expectedProductTitle);
|
|
5827
|
+
cartOpenMethod = openResult.method;
|
|
5828
|
+
} else {
|
|
5829
|
+
const alreadyOpen = await isCartRevealed(page, expectedProductTitle);
|
|
5830
|
+
if (alreadyOpen) {
|
|
5831
|
+
cartOpenMethod = "already-open";
|
|
5832
|
+
dlog2(ctx, `step 7 open-minicart: no trigger, drawer already visible (${alreadyOpen})`);
|
|
5833
|
+
}
|
|
5425
5834
|
}
|
|
5426
|
-
|
|
5427
|
-
|
|
5835
|
+
let step7Validation;
|
|
5836
|
+
if (expectedProductTitle && cartOpenMethod !== "failed") {
|
|
5837
|
+
const v = await validateCartContainsTitle(page, expectedProductTitle, ctx);
|
|
5838
|
+
let reasonText;
|
|
5839
|
+
if (!v.found) {
|
|
5840
|
+
if (v.observedTitles.length === 0) {
|
|
5841
|
+
const emptyBanner = await detectEmptyCartBanner(page);
|
|
5842
|
+
reasonText = emptyBanner ? `cart genuinely empty — add-to-cart didn't persist. Banner: "${emptyBanner}"` : "no cart line items visible (selectors may not match markup)";
|
|
5843
|
+
} else {
|
|
5844
|
+
reasonText = `expected title not found among ${v.observedTitles.length} observed`;
|
|
5845
|
+
}
|
|
5846
|
+
}
|
|
5847
|
+
step7Validation = {
|
|
5848
|
+
expectedTitle: expectedProductTitle,
|
|
5849
|
+
found: v.found,
|
|
5850
|
+
method: v.method,
|
|
5851
|
+
observedTitles: v.observedTitles.slice(0, 8),
|
|
5852
|
+
reason: reasonText
|
|
5853
|
+
};
|
|
5854
|
+
dlog2(ctx, `step 7 open-minicart: validation → found=${v.found} (${v.method})${reasonText ? ` — ${reasonText.slice(0, 80)}` : ""}`);
|
|
5855
|
+
}
|
|
5856
|
+
const sp7 = screenshotPath(ctx, "pj-7-minicart");
|
|
5857
|
+
await page.screenshot({ path: sp7, fullPage: false }).catch(() => {
|
|
5428
5858
|
return;
|
|
5429
5859
|
});
|
|
5860
|
+
const step7Status = cartOpenMethod === "failed" ? "failed" : step7Validation && !step7Validation.found ? "failed" : "ok";
|
|
5430
5861
|
steps.push({
|
|
5431
|
-
step:
|
|
5862
|
+
step: 7,
|
|
5432
5863
|
name: "open-minicart",
|
|
5433
5864
|
side: ctx.side,
|
|
5434
5865
|
viewport: ctx.viewport,
|
|
5435
|
-
status:
|
|
5436
|
-
durationMs: Date.now() -
|
|
5866
|
+
status: step7Status,
|
|
5867
|
+
durationMs: Date.now() - t7,
|
|
5437
5868
|
url: page.url(),
|
|
5438
|
-
screenshotPath:
|
|
5439
|
-
screenshotBeforePath:
|
|
5440
|
-
beforeUrl:
|
|
5441
|
-
|
|
5869
|
+
screenshotPath: sp7,
|
|
5870
|
+
screenshotBeforePath: spBefore7,
|
|
5871
|
+
beforeUrl: beforeUrl7,
|
|
5872
|
+
cartOpenMethod,
|
|
5873
|
+
cartValidation: step7Validation,
|
|
5874
|
+
actionDescription: miniHit ? `Abriu minicart via ${cartOpenMethod}${miniText ? ` em '${miniText.slice(0, 30).trim()}'` : ""} (\`${miniHit.selector}\`)${miniRecovered ? " — selector via recovery LLM" : ""}${step7Validation ? step7Validation.found ? " ✓ produto encontrado no cart" : ` ✗ produto ESPERADO não encontrado (${step7Validation.observedTitles?.length ?? 0} itens observados)` : ""}` : cartOpenMethod === "already-open" ? "Minicart já estava aberto após add-to-cart" : "Minicart não pôde ser aberto",
|
|
5442
5875
|
selectorKey: miniHit ? "minicartTrigger" : undefined,
|
|
5443
5876
|
usedSelector: miniHit?.selector,
|
|
5444
5877
|
recoveredByLlm: miniRecovered || undefined
|
|
5445
5878
|
});
|
|
5446
|
-
reportEnd(
|
|
5447
|
-
reportStart(
|
|
5879
|
+
reportEnd(7, "open-minicart", step7Status, Date.now() - t7);
|
|
5880
|
+
reportStart(8, "shipping-calc-cart");
|
|
5448
5881
|
let cepInputCart = await firstVisible(page, selFor(ctx, "cepInputCart"));
|
|
5449
|
-
let
|
|
5450
|
-
if (!cepInputCart &&
|
|
5451
|
-
const
|
|
5452
|
-
if (
|
|
5453
|
-
|
|
5454
|
-
|
|
5455
|
-
|
|
5882
|
+
let cepCartRecoveredByLlm = false;
|
|
5883
|
+
if (!cepInputCart && budget.remaining > 0) {
|
|
5884
|
+
const html = await page.content().catch(() => "");
|
|
5885
|
+
if (html) {
|
|
5886
|
+
const suggestion = await suggestRecovery({
|
|
5887
|
+
stepName: "shipping-calc-cart",
|
|
5888
|
+
intendedAction: "Encontre o <input> de CEP (código postal de ENDEREÇO de entrega) dentro do minicart drawer ou da página de carrinho — usado pra CALCULAR FRETE antes do checkout. Placeholder normalmente é 'CEP', 'Digite seu CEP', 'Cód. postal', 'Postal code', aria-label='CEP' ou name='zip'/'cep'/'postal'. **NÃO** retorne o input de CUPOM de desconto / 'Digite o cupom' / código promocional — esses são pra desconto, não pra frete. Também NÃO retorne campo de e-mail/newsletter.",
|
|
5889
|
+
html,
|
|
5890
|
+
alreadyTried: selFor(ctx, "cepInputCart")
|
|
5891
|
+
});
|
|
5892
|
+
if (suggestion) {
|
|
5893
|
+
budget.remaining--;
|
|
5894
|
+
const el = page.locator(suggestion.selector).first();
|
|
5895
|
+
if (await el.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
5896
|
+
cepInputCart = suggestion.selector;
|
|
5897
|
+
cepCartRecoveredByLlm = true;
|
|
5898
|
+
}
|
|
5899
|
+
}
|
|
5456
5900
|
}
|
|
5457
5901
|
}
|
|
5458
5902
|
if (cepInputCart) {
|
|
5459
|
-
const
|
|
5460
|
-
const
|
|
5461
|
-
const
|
|
5462
|
-
await page.screenshot({ path:
|
|
5903
|
+
const t8 = Date.now();
|
|
5904
|
+
const beforeUrl8 = page.url();
|
|
5905
|
+
const spBefore8 = screenshotPath(ctx, "pj-8-shipping-cart-before");
|
|
5906
|
+
await page.screenshot({ path: spBefore8, fullPage: false }).catch(() => {
|
|
5463
5907
|
return;
|
|
5464
5908
|
});
|
|
5465
5909
|
const ok = await fillCep(page, cepInputCart, ctx.rc.cep);
|
|
5466
|
-
const
|
|
5467
|
-
await page.screenshot({ path:
|
|
5910
|
+
const sp8 = screenshotPath(ctx, "pj-8-shipping-cart");
|
|
5911
|
+
await page.screenshot({ path: sp8, fullPage: false }).catch(() => {
|
|
5468
5912
|
return;
|
|
5469
5913
|
});
|
|
5470
|
-
const
|
|
5914
|
+
const step8Status = ok ? "ok" : "failed";
|
|
5471
5915
|
steps.push({
|
|
5472
|
-
step:
|
|
5916
|
+
step: 8,
|
|
5473
5917
|
name: "shipping-calc-cart",
|
|
5474
5918
|
side: ctx.side,
|
|
5475
5919
|
viewport: ctx.viewport,
|
|
5476
|
-
status:
|
|
5477
|
-
durationMs: Date.now() -
|
|
5920
|
+
status: step8Status,
|
|
5921
|
+
durationMs: Date.now() - t8,
|
|
5478
5922
|
url: page.url(),
|
|
5479
|
-
screenshotPath:
|
|
5480
|
-
screenshotBeforePath:
|
|
5481
|
-
beforeUrl:
|
|
5482
|
-
actionDescription: `Preencheu CEP '${ctx.rc.cep}' no carrinho (\`${cepInputCart}\`)${
|
|
5923
|
+
screenshotPath: sp8,
|
|
5924
|
+
screenshotBeforePath: spBefore8,
|
|
5925
|
+
beforeUrl: beforeUrl8,
|
|
5926
|
+
actionDescription: `Preencheu CEP '${ctx.rc.cep}' no carrinho (\`${cepInputCart}\`)${cepCartRecoveredByLlm ? " — recovery LLM" : ""}`,
|
|
5483
5927
|
detail: { cepUsed: ctx.rc.cep },
|
|
5484
5928
|
selectorKey: "cepInputCart",
|
|
5485
5929
|
usedSelector: cepInputCart,
|
|
5486
|
-
recoveredByLlm:
|
|
5930
|
+
recoveredByLlm: cepCartRecoveredByLlm || undefined
|
|
5487
5931
|
});
|
|
5488
|
-
reportEnd(
|
|
5932
|
+
reportEnd(8, "shipping-calc-cart", step8Status, Date.now() - t8);
|
|
5489
5933
|
} else {
|
|
5490
|
-
steps.push(makeSkipStep(
|
|
5491
|
-
reportEnd(
|
|
5934
|
+
steps.push(makeSkipStep(8, "shipping-calc-cart", ctx, "no CEP input in cart (recovery exhausted)"));
|
|
5935
|
+
reportEnd(8, "shipping-calc-cart", "skipped", 0, "no CEP input in cart");
|
|
5936
|
+
}
|
|
5937
|
+
reportStart(9, "go-checkout");
|
|
5938
|
+
const t9 = Date.now();
|
|
5939
|
+
const beforeUrl9 = page.url();
|
|
5940
|
+
if (/\/(checkout|carrinho)(\/|$|\?)/i.test(beforeUrl9)) {
|
|
5941
|
+
await waitForCartHydration(page);
|
|
5942
|
+
let step9EarlyValidation;
|
|
5943
|
+
if (expectedProductTitle) {
|
|
5944
|
+
const v = await validateCartContainsTitle(page, expectedProductTitle, ctx);
|
|
5945
|
+
step9EarlyValidation = {
|
|
5946
|
+
expectedTitle: expectedProductTitle,
|
|
5947
|
+
found: v.found,
|
|
5948
|
+
method: v.method,
|
|
5949
|
+
observedTitles: v.observedTitles.slice(0, 8),
|
|
5950
|
+
reason: v.found ? undefined : `expected title not found among ${v.observedTitles.length} observed on checkout`
|
|
5951
|
+
};
|
|
5952
|
+
}
|
|
5953
|
+
const sp9early = screenshotPath(ctx, "pj-9-checkout-reached");
|
|
5954
|
+
await page.screenshot({ path: sp9early, fullPage: false }).catch(() => {
|
|
5955
|
+
return;
|
|
5956
|
+
});
|
|
5957
|
+
const earlyStatus = step9EarlyValidation && !step9EarlyValidation.found ? "failed" : "ok";
|
|
5958
|
+
steps.push({
|
|
5959
|
+
step: 9,
|
|
5960
|
+
name: "go-checkout",
|
|
5961
|
+
side: ctx.side,
|
|
5962
|
+
viewport: ctx.viewport,
|
|
5963
|
+
status: earlyStatus,
|
|
5964
|
+
durationMs: Date.now() - t9,
|
|
5965
|
+
url: page.url(),
|
|
5966
|
+
screenshotPath: sp9early,
|
|
5967
|
+
cartValidation: step9EarlyValidation,
|
|
5968
|
+
actionDescription: `Já estava em \`${beforeUrl9}\` (minicart navegou direto para checkout)${step9EarlyValidation ? step9EarlyValidation.found ? " ✓ produto persiste no checkout" : ` ✗ produto ESPERADO ausente (${step9EarlyValidation.observedTitles?.length ?? 0} observados)` : ""}`,
|
|
5969
|
+
detail: { reachedVia: "minicart-direct-link" }
|
|
5970
|
+
});
|
|
5971
|
+
reportEnd(9, "go-checkout", earlyStatus, Date.now() - t9);
|
|
5972
|
+
return { pages, steps };
|
|
5492
5973
|
}
|
|
5493
|
-
reportStart(8, "go-checkout");
|
|
5494
5974
|
let checkoutHit = await firstVisibleLocator(page, selFor(ctx, "checkoutButton"));
|
|
5495
5975
|
let checkoutRecovered = false;
|
|
5496
|
-
if (!checkoutHit &&
|
|
5497
|
-
const recovery = await attemptRecovery(page, ctx, "go-checkout", "Clicar no botão 'Finalizar compra' / 'Ir para o checkout'", selFor(ctx, "checkoutButton"));
|
|
5976
|
+
if (!checkoutHit && budget.remaining > 0) {
|
|
5977
|
+
const recovery = await attemptRecovery(page, ctx, "go-checkout", "Clicar no botão 'Finalizar compra' / 'Ir para o checkout' / 'Finalizar'", selFor(ctx, "checkoutButton"));
|
|
5498
5978
|
if (recovery) {
|
|
5499
5979
|
checkoutHit = recovery;
|
|
5500
5980
|
checkoutRecovered = true;
|
|
5501
|
-
|
|
5981
|
+
budget.remaining--;
|
|
5502
5982
|
}
|
|
5503
5983
|
}
|
|
5504
5984
|
if (!checkoutHit) {
|
|
5505
|
-
steps.push(makeSkipStep(
|
|
5506
|
-
reportEnd(
|
|
5985
|
+
steps.push(makeSkipStep(9, "go-checkout", ctx, "no checkout button found (recovery exhausted)"));
|
|
5986
|
+
reportEnd(9, "go-checkout", "skipped", 0, "no checkout button found");
|
|
5507
5987
|
return { pages, steps };
|
|
5508
5988
|
}
|
|
5509
|
-
const
|
|
5510
|
-
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5989
|
+
const spBefore9 = screenshotPath(ctx, "pj-9-checkout-before");
|
|
5990
|
+
await page.screenshot({ path: spBefore9, fullPage: false }).catch(() => {
|
|
5991
|
+
return;
|
|
5992
|
+
});
|
|
5993
|
+
let usedSelector = checkoutHit.selector;
|
|
5994
|
+
let clickedText = await checkoutHit.locator.innerText().catch(() => "");
|
|
5995
|
+
let reachedCheckout = false;
|
|
5996
|
+
let attempt = 0;
|
|
5997
|
+
const triedSelectors = new Set([usedSelector]);
|
|
5998
|
+
while (attempt < 3) {
|
|
5999
|
+
attempt++;
|
|
5515
6000
|
await Promise.all([
|
|
5516
|
-
page.waitForURL(
|
|
6001
|
+
page.waitForURL(/\/(checkout|carrinho)/i, { timeout: 1e4 }).catch(() => {
|
|
5517
6002
|
return;
|
|
5518
6003
|
}),
|
|
5519
|
-
|
|
6004
|
+
checkoutHit.locator.click({ timeout: 5000 }).catch(() => {
|
|
5520
6005
|
return;
|
|
5521
6006
|
})
|
|
5522
6007
|
]);
|
|
5523
6008
|
await page.waitForTimeout(1500);
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
|
|
5527
|
-
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
6009
|
+
reachedCheckout = /\/(checkout|carrinho)(\/|$|\?)/i.test(page.url());
|
|
6010
|
+
if (reachedCheckout)
|
|
6011
|
+
break;
|
|
6012
|
+
if (budget.remaining <= 0)
|
|
6013
|
+
break;
|
|
6014
|
+
dlog2(ctx, `step 9 go-checkout: click on \`${usedSelector}\` didn't navigate (attempt ${attempt}). URL still ${page.url()}. Asking LLM for another selector.`);
|
|
6015
|
+
const triedList = Array.from(triedSelectors);
|
|
6016
|
+
const recovery = await attemptRecovery(page, ctx, "go-checkout", `O click anterior em \`${usedSelector}\` não navegou para /checkout (URL continua em ${page.url()}). Encontre OUTRO selector — o botão real de finalizar compra deve ser visível agora (no drawer aberto ou na página). Não retorne ${triedList.join(", ")} novamente.`, triedList);
|
|
6017
|
+
if (!recovery)
|
|
6018
|
+
break;
|
|
6019
|
+
checkoutHit = recovery;
|
|
6020
|
+
checkoutRecovered = true;
|
|
6021
|
+
usedSelector = recovery.selector;
|
|
6022
|
+
triedSelectors.add(usedSelector);
|
|
6023
|
+
clickedText = await recovery.locator.innerText().catch(() => "");
|
|
6024
|
+
budget.remaining--;
|
|
6025
|
+
}
|
|
6026
|
+
let step9Validation;
|
|
6027
|
+
if (reachedCheckout && expectedProductTitle) {
|
|
6028
|
+
await waitForCartHydration(page);
|
|
6029
|
+
const v = await validateCartContainsTitle(page, expectedProductTitle, ctx);
|
|
6030
|
+
step9Validation = {
|
|
6031
|
+
expectedTitle: expectedProductTitle,
|
|
6032
|
+
found: v.found,
|
|
6033
|
+
method: v.method,
|
|
6034
|
+
observedTitles: v.observedTitles.slice(0, 8),
|
|
6035
|
+
reason: v.found ? undefined : `expected title not found among ${v.observedTitles.length} observed on checkout`
|
|
6036
|
+
};
|
|
5549
6037
|
}
|
|
5550
|
-
const
|
|
6038
|
+
const sp9 = screenshotPath(ctx, "pj-9-checkout-reached");
|
|
6039
|
+
await page.screenshot({ path: sp9, fullPage: false }).catch(() => {
|
|
6040
|
+
return;
|
|
6041
|
+
});
|
|
6042
|
+
const step9Status = !reachedCheckout ? "failed" : step9Validation && !step9Validation.found ? "failed" : "ok";
|
|
5551
6043
|
steps.push({
|
|
5552
|
-
step:
|
|
6044
|
+
step: 9,
|
|
5553
6045
|
name: "go-checkout",
|
|
5554
6046
|
side: ctx.side,
|
|
5555
6047
|
viewport: ctx.viewport,
|
|
5556
|
-
status:
|
|
5557
|
-
durationMs: Date.now() -
|
|
5558
|
-
url:
|
|
5559
|
-
screenshotPath:
|
|
5560
|
-
screenshotBeforePath:
|
|
5561
|
-
beforeUrl:
|
|
5562
|
-
|
|
6048
|
+
status: step9Status,
|
|
6049
|
+
durationMs: Date.now() - t9,
|
|
6050
|
+
url: page.url(),
|
|
6051
|
+
screenshotPath: sp9,
|
|
6052
|
+
screenshotBeforePath: spBefore9,
|
|
6053
|
+
beforeUrl: beforeUrl9,
|
|
6054
|
+
cartValidation: step9Validation,
|
|
6055
|
+
actionDescription: `Clicou em${clickedText ? ` '${clickedText.slice(0, 30).trim()}'` : ""} (\`${usedSelector}\`); URL final: ${page.url()}${reachedCheckout ? " ✓ atingiu checkout" : " ✗ não foi pra checkout"}${attempt > 1 ? ` (após ${attempt} tentativas com recovery LLM)` : ""}${step9Validation ? step9Validation.found ? " ✓ produto persiste no checkout" : ` ✗ produto ESPERADO ausente no checkout (${step9Validation.observedTitles?.length ?? 0} observados)` : ""}`,
|
|
5563
6056
|
selectorKey: "checkoutButton",
|
|
5564
6057
|
usedSelector,
|
|
5565
6058
|
recoveredByLlm: checkoutRecovered || undefined
|
|
5566
6059
|
});
|
|
5567
|
-
reportEnd(
|
|
6060
|
+
reportEnd(9, "go-checkout", step9Status, Date.now() - t9);
|
|
5568
6061
|
return { pages, steps };
|
|
5569
6062
|
} finally {
|
|
5570
6063
|
await page.close();
|
|
@@ -5680,6 +6173,268 @@ async function fillCep(page, selector, cep) {
|
|
|
5680
6173
|
return false;
|
|
5681
6174
|
}
|
|
5682
6175
|
}
|
|
6176
|
+
async function selectVariant(page, ctx) {
|
|
6177
|
+
const actions = [];
|
|
6178
|
+
let primarySelectorKey;
|
|
6179
|
+
let primarySelector;
|
|
6180
|
+
const trackPrimary = (key, sel) => {
|
|
6181
|
+
if (!primarySelectorKey) {
|
|
6182
|
+
primarySelectorKey = key;
|
|
6183
|
+
primarySelector = sel;
|
|
6184
|
+
}
|
|
6185
|
+
};
|
|
6186
|
+
try {
|
|
6187
|
+
const rowSelectors = selFor(ctx, "variantRow");
|
|
6188
|
+
const incrementSelectors = selFor(ctx, "quantityIncrement");
|
|
6189
|
+
rowLoop:
|
|
6190
|
+
for (const rowSel of rowSelectors) {
|
|
6191
|
+
const rows = page.locator(rowSel);
|
|
6192
|
+
const count = await rows.count().catch(() => 0);
|
|
6193
|
+
for (let i = 0;i < Math.min(count, 5); i++) {
|
|
6194
|
+
const row = rows.nth(i);
|
|
6195
|
+
if (!await row.isVisible({ timeout: 500 }).catch(() => false))
|
|
6196
|
+
continue;
|
|
6197
|
+
const rowText = (await row.innerText().catch(() => "")).replace(/\s+/g, " ").trim();
|
|
6198
|
+
for (const incSel of incrementSelectors) {
|
|
6199
|
+
const plus = row.locator(incSel).first();
|
|
6200
|
+
if (!await plus.isVisible({ timeout: 500 }).catch(() => false))
|
|
6201
|
+
continue;
|
|
6202
|
+
if (await plus.isDisabled().catch(() => false))
|
|
6203
|
+
continue;
|
|
6204
|
+
await plus.click({ timeout: 2000 }).catch(() => {
|
|
6205
|
+
return;
|
|
6206
|
+
});
|
|
6207
|
+
await page.waitForTimeout(400);
|
|
6208
|
+
actions.push(`Incrementou quantidade da variante \`${rowSel}\`[${i}]${rowText ? ` (${rowText.slice(0, 40)})` : ""} via \`${incSel}\``);
|
|
6209
|
+
trackPrimary("variantRow", rowSel);
|
|
6210
|
+
break rowLoop;
|
|
6211
|
+
}
|
|
6212
|
+
}
|
|
6213
|
+
}
|
|
6214
|
+
} catch {}
|
|
6215
|
+
if (actions.length === 0) {
|
|
6216
|
+
const sizeHit = await firstVisibleLocator(page, selFor(ctx, "sizeSwatch"));
|
|
6217
|
+
if (sizeHit && !await sizeHit.locator.isDisabled().catch(() => false)) {
|
|
6218
|
+
const sizeText = (await sizeHit.locator.innerText().catch(() => "")).slice(0, 20).trim();
|
|
6219
|
+
await sizeHit.locator.click({ timeout: 2000 }).catch(() => {
|
|
6220
|
+
return;
|
|
6221
|
+
});
|
|
6222
|
+
await page.waitForTimeout(400);
|
|
6223
|
+
actions.push(`Selecionou tamanho${sizeText ? ` '${sizeText}'` : ""} (\`${sizeHit.selector}\`)`);
|
|
6224
|
+
trackPrimary("sizeSwatch", sizeHit.selector);
|
|
6225
|
+
}
|
|
6226
|
+
}
|
|
6227
|
+
const colorHit = await firstVisibleLocator(page, selFor(ctx, "colorSwatch"));
|
|
6228
|
+
if (colorHit && !await colorHit.locator.isDisabled().catch(() => false)) {
|
|
6229
|
+
const colorText = (await colorHit.locator.innerText().catch(() => "")).slice(0, 20).trim();
|
|
6230
|
+
const colorLabel = colorText || await colorHit.locator.getAttribute("aria-label").catch(() => null) || "";
|
|
6231
|
+
await colorHit.locator.click({ timeout: 2000 }).catch(() => {
|
|
6232
|
+
return;
|
|
6233
|
+
});
|
|
6234
|
+
await page.waitForTimeout(400);
|
|
6235
|
+
actions.push(`Selecionou cor${colorLabel ? ` '${colorLabel}'` : ""} (\`${colorHit.selector}\`)`);
|
|
6236
|
+
trackPrimary("colorSwatch", colorHit.selector);
|
|
6237
|
+
}
|
|
6238
|
+
if (actions.length === 0) {
|
|
6239
|
+
await scrollPageInChunks(page);
|
|
6240
|
+
const result = await findAndIncrementZeroQtyInput(page, ctx);
|
|
6241
|
+
if (result) {
|
|
6242
|
+
actions.push(result.description);
|
|
6243
|
+
trackPrimary(result.selectorKey, result.usedSelector);
|
|
6244
|
+
}
|
|
6245
|
+
}
|
|
6246
|
+
let variantRequired = false;
|
|
6247
|
+
try {
|
|
6248
|
+
const bodyText = await page.locator("body").innerText({ timeout: 1000 }).catch(() => "");
|
|
6249
|
+
variantRequired = VARIANT_REQUIRED_TEXT_PATTERNS.some((re) => re.test(bodyText));
|
|
6250
|
+
} catch {}
|
|
6251
|
+
return { actions, primarySelectorKey, primarySelector, variantRequired };
|
|
6252
|
+
}
|
|
6253
|
+
async function scrollPageInChunks(page) {
|
|
6254
|
+
await page.evaluate(async () => {
|
|
6255
|
+
const height = document.body.scrollHeight;
|
|
6256
|
+
const stops = [0.2, 0.4, 0.6, 0.8, 1, 0.4];
|
|
6257
|
+
for (const frac of stops) {
|
|
6258
|
+
window.scrollTo({ top: height * frac, behavior: "instant" });
|
|
6259
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
6260
|
+
}
|
|
6261
|
+
window.scrollTo({ top: 0, behavior: "instant" });
|
|
6262
|
+
}).catch(() => {
|
|
6263
|
+
return;
|
|
6264
|
+
});
|
|
6265
|
+
await page.waitForTimeout(1200);
|
|
6266
|
+
}
|
|
6267
|
+
async function findAndIncrementZeroQtyInput(page, ctx) {
|
|
6268
|
+
const qtySelectors = selFor(ctx, "quantityInput");
|
|
6269
|
+
const scopeSelectors = [
|
|
6270
|
+
"[data-manifest-key*='ProductDetails' i]",
|
|
6271
|
+
"[data-manifest-key*='Product/' i]",
|
|
6272
|
+
"main"
|
|
6273
|
+
];
|
|
6274
|
+
const scopeRoots = [];
|
|
6275
|
+
for (const sel of scopeSelectors) {
|
|
6276
|
+
const cand = page.locator(sel).first();
|
|
6277
|
+
if (await cand.count().then((n) => n > 0).catch(() => false)) {
|
|
6278
|
+
scopeRoots.push({ root: cand, tag: "main" });
|
|
6279
|
+
break;
|
|
6280
|
+
}
|
|
6281
|
+
}
|
|
6282
|
+
scopeRoots.push({ root: null, tag: "page" });
|
|
6283
|
+
for (const { root, tag } of scopeRoots) {
|
|
6284
|
+
for (const sel of qtySelectors) {
|
|
6285
|
+
const all = root ? root.locator(sel) : page.locator(sel);
|
|
6286
|
+
const count = await all.count().catch(() => 0);
|
|
6287
|
+
for (let i = 0;i < Math.min(count, 20); i++) {
|
|
6288
|
+
const el = all.nth(i);
|
|
6289
|
+
if (!await el.isVisible({ timeout: 300 }).catch(() => false))
|
|
6290
|
+
continue;
|
|
6291
|
+
const value = (await el.inputValue().catch(() => "")).trim();
|
|
6292
|
+
if (value !== "" && value !== "0")
|
|
6293
|
+
continue;
|
|
6294
|
+
const plus = await findPlusButtonNear(el);
|
|
6295
|
+
if (plus) {
|
|
6296
|
+
if (await plus.isDisabled().catch(() => false))
|
|
6297
|
+
continue;
|
|
6298
|
+
await plus.click({ timeout: 2000 }).catch(() => {
|
|
6299
|
+
return;
|
|
6300
|
+
});
|
|
6301
|
+
await page.waitForTimeout(500);
|
|
6302
|
+
return {
|
|
6303
|
+
description: `Incrementou variante via botão '+' próximo ao input \`${sel}\` (scope=${tag}, índice ${i})`,
|
|
6304
|
+
selectorKey: "quantityInput",
|
|
6305
|
+
usedSelector: sel
|
|
6306
|
+
};
|
|
6307
|
+
}
|
|
6308
|
+
await el.fill("1", { timeout: 1500 }).catch(() => {
|
|
6309
|
+
return;
|
|
6310
|
+
});
|
|
6311
|
+
await el.dispatchEvent("input").catch(() => {
|
|
6312
|
+
return;
|
|
6313
|
+
});
|
|
6314
|
+
await el.dispatchEvent("change").catch(() => {
|
|
6315
|
+
return;
|
|
6316
|
+
});
|
|
6317
|
+
await page.waitForTimeout(400);
|
|
6318
|
+
return {
|
|
6319
|
+
description: `Ajustou quantidade do input \`${sel}\` para 1 (scope=${tag}, índice ${i})`,
|
|
6320
|
+
selectorKey: "quantityInput",
|
|
6321
|
+
usedSelector: sel
|
|
6322
|
+
};
|
|
6323
|
+
}
|
|
6324
|
+
}
|
|
6325
|
+
}
|
|
6326
|
+
return null;
|
|
6327
|
+
}
|
|
6328
|
+
async function findPlusButtonNear(input) {
|
|
6329
|
+
const ancestorXPath = "xpath=ancestor::*[self::li or self::tr or self::form or self::fieldset or @data-sku-row or @data-variant-row][1]//button[normalize-space(.)='+']";
|
|
6330
|
+
const inAncestor = input.locator(ancestorXPath).first();
|
|
6331
|
+
if (await inAncestor.isVisible({ timeout: 600 }).catch(() => false)) {
|
|
6332
|
+
return inAncestor;
|
|
6333
|
+
}
|
|
6334
|
+
for (let depth = 1;depth <= 6; depth++) {
|
|
6335
|
+
const climb = `xpath=ancestor::*[${depth}]//button[normalize-space(.)='+']`;
|
|
6336
|
+
const cand = input.locator(climb).first();
|
|
6337
|
+
if (await cand.isVisible({ timeout: 300 }).catch(() => false)) {
|
|
6338
|
+
return cand;
|
|
6339
|
+
}
|
|
6340
|
+
}
|
|
6341
|
+
const sibling = input.locator("xpath=following-sibling::button[1]").first();
|
|
6342
|
+
if (await sibling.isVisible({ timeout: 300 }).catch(() => false)) {
|
|
6343
|
+
const text = (await sibling.innerText().catch(() => "")).trim();
|
|
6344
|
+
if (text === "+")
|
|
6345
|
+
return sibling;
|
|
6346
|
+
}
|
|
6347
|
+
return null;
|
|
6348
|
+
}
|
|
6349
|
+
async function readCartCount(page, ctx) {
|
|
6350
|
+
const selectors = selFor(ctx, "minicartCount");
|
|
6351
|
+
for (const sel of selectors) {
|
|
6352
|
+
try {
|
|
6353
|
+
const el = page.locator(sel).first();
|
|
6354
|
+
if (!await el.isVisible({ timeout: 500 }).catch(() => false))
|
|
6355
|
+
continue;
|
|
6356
|
+
const raw = (await el.innerText().catch(() => "")).trim();
|
|
6357
|
+
const match = raw.match(/\d+/);
|
|
6358
|
+
if (match)
|
|
6359
|
+
return Number.parseInt(match[0], 10);
|
|
6360
|
+
} catch {}
|
|
6361
|
+
}
|
|
6362
|
+
return 0;
|
|
6363
|
+
}
|
|
6364
|
+
async function validateAddToCart(page, ctx, cartCountBefore, beforeUrl) {
|
|
6365
|
+
const deadline = Date.now() + 3000;
|
|
6366
|
+
let lastErrorText;
|
|
6367
|
+
while (Date.now() < deadline) {
|
|
6368
|
+
const currentUrl = page.url();
|
|
6369
|
+
if (currentUrl !== beforeUrl && /\/(cart|carrinho|checkout)(\/|$|\?)/i.test(currentUrl)) {
|
|
6370
|
+
return {
|
|
6371
|
+
status: "ok",
|
|
6372
|
+
signal: "url-changed",
|
|
6373
|
+
note: `URL mudou para \`${currentUrl}\` (carrinho/checkout)`
|
|
6374
|
+
};
|
|
6375
|
+
}
|
|
6376
|
+
const cartCountNow = await readCartCount(page, ctx);
|
|
6377
|
+
if (cartCountNow > cartCountBefore) {
|
|
6378
|
+
return {
|
|
6379
|
+
status: "ok",
|
|
6380
|
+
signal: "count-increased",
|
|
6381
|
+
note: `Contador do minicart foi de ${cartCountBefore} para ${cartCountNow}`
|
|
6382
|
+
};
|
|
6383
|
+
}
|
|
6384
|
+
const drawerSelectors = [
|
|
6385
|
+
"[role='dialog']",
|
|
6386
|
+
"[data-minicart][aria-expanded='true']",
|
|
6387
|
+
"[data-cart-drawer].open",
|
|
6388
|
+
"[data-minicart-drawer]:not([hidden])",
|
|
6389
|
+
".minicart--open",
|
|
6390
|
+
".cart-drawer--open"
|
|
6391
|
+
];
|
|
6392
|
+
for (const sel of drawerSelectors) {
|
|
6393
|
+
const el = page.locator(sel).first();
|
|
6394
|
+
if (await el.isVisible({ timeout: 200 }).catch(() => false)) {
|
|
6395
|
+
return {
|
|
6396
|
+
status: "ok",
|
|
6397
|
+
signal: "drawer-open",
|
|
6398
|
+
note: `Drawer/modal do carrinho abriu (\`${sel}\`)`
|
|
6399
|
+
};
|
|
6400
|
+
}
|
|
6401
|
+
}
|
|
6402
|
+
try {
|
|
6403
|
+
const bodyText = await page.locator("body").innerText({ timeout: 500 }).catch(() => "");
|
|
6404
|
+
for (const re of ADD_TO_CART_SUCCESS_PATTERNS) {
|
|
6405
|
+
const match = bodyText.match(re);
|
|
6406
|
+
if (match) {
|
|
6407
|
+
return {
|
|
6408
|
+
status: "ok",
|
|
6409
|
+
signal: "success-toast",
|
|
6410
|
+
note: `Toast de sucesso visível: '${match[0]}'`
|
|
6411
|
+
};
|
|
6412
|
+
}
|
|
6413
|
+
}
|
|
6414
|
+
for (const re of ADD_TO_CART_ERROR_PATTERNS) {
|
|
6415
|
+
const match = bodyText.match(re);
|
|
6416
|
+
if (match) {
|
|
6417
|
+
lastErrorText = match[0];
|
|
6418
|
+
break;
|
|
6419
|
+
}
|
|
6420
|
+
}
|
|
6421
|
+
} catch {}
|
|
6422
|
+
await page.waitForTimeout(250);
|
|
6423
|
+
}
|
|
6424
|
+
if (lastErrorText) {
|
|
6425
|
+
return {
|
|
6426
|
+
status: "failed",
|
|
6427
|
+
signal: "error-text",
|
|
6428
|
+
note: `add-to-cart silenciosamente falhou: '${lastErrorText}' visível na página`,
|
|
6429
|
+
errorText: lastErrorText
|
|
6430
|
+
};
|
|
6431
|
+
}
|
|
6432
|
+
return {
|
|
6433
|
+
status: "failed",
|
|
6434
|
+
signal: "no-signal",
|
|
6435
|
+
note: "add-to-cart sem confirmação visível (minicart não atualizou, sem drawer, sem mudança de URL)"
|
|
6436
|
+
};
|
|
6437
|
+
}
|
|
5683
6438
|
async function attemptRecovery(page, _ctx, stepName, intendedAction, alreadyTried) {
|
|
5684
6439
|
let html = "";
|
|
5685
6440
|
try {
|
|
@@ -5698,6 +6453,460 @@ async function attemptRecovery(page, _ctx, stepName, intendedAction, alreadyTrie
|
|
|
5698
6453
|
} catch {}
|
|
5699
6454
|
return null;
|
|
5700
6455
|
}
|
|
6456
|
+
async function attemptStepAction(args) {
|
|
6457
|
+
const { page, ctx, stepName, intendedAction, selectorKey, action, value, recoveryBudget } = args;
|
|
6458
|
+
const selectors = selFor(ctx, selectorKey);
|
|
6459
|
+
for (const sel of selectors) {
|
|
6460
|
+
try {
|
|
6461
|
+
const el = page.locator(sel).first();
|
|
6462
|
+
if (!await el.isVisible({ timeout: 800 }).catch(() => false))
|
|
6463
|
+
continue;
|
|
6464
|
+
const ok = await performAction(el, action, value);
|
|
6465
|
+
if (ok) {
|
|
6466
|
+
return { performed: true, selector: sel, action, recoveredByLlm: false };
|
|
6467
|
+
}
|
|
6468
|
+
} catch {}
|
|
6469
|
+
}
|
|
6470
|
+
if (recoveryBudget.remaining <= 0) {
|
|
6471
|
+
return { performed: false, selector: "", action, recoveredByLlm: false };
|
|
6472
|
+
}
|
|
6473
|
+
let html = "";
|
|
6474
|
+
try {
|
|
6475
|
+
html = await page.content();
|
|
6476
|
+
} catch {
|
|
6477
|
+
return { performed: false, selector: "", action, recoveredByLlm: false };
|
|
6478
|
+
}
|
|
6479
|
+
const suggestion = await suggestRecovery({
|
|
6480
|
+
stepName,
|
|
6481
|
+
intendedAction,
|
|
6482
|
+
html,
|
|
6483
|
+
alreadyTried: selectors
|
|
6484
|
+
});
|
|
6485
|
+
if (!suggestion) {
|
|
6486
|
+
return { performed: false, selector: "", action, recoveredByLlm: false };
|
|
6487
|
+
}
|
|
6488
|
+
recoveryBudget.remaining--;
|
|
6489
|
+
const llmAction = suggestion.action ?? action;
|
|
6490
|
+
const llmValue = suggestion.value ?? value;
|
|
6491
|
+
try {
|
|
6492
|
+
const el = page.locator(suggestion.selector).first();
|
|
6493
|
+
if (!await el.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
6494
|
+
return { performed: false, selector: "", action: llmAction, recoveredByLlm: false };
|
|
6495
|
+
}
|
|
6496
|
+
const ok = await performAction(el, llmAction, llmValue);
|
|
6497
|
+
if (ok) {
|
|
6498
|
+
return {
|
|
6499
|
+
performed: true,
|
|
6500
|
+
selector: suggestion.selector,
|
|
6501
|
+
action: llmAction,
|
|
6502
|
+
recoveredByLlm: true
|
|
6503
|
+
};
|
|
6504
|
+
}
|
|
6505
|
+
} catch {}
|
|
6506
|
+
return { performed: false, selector: "", action: llmAction, recoveredByLlm: false };
|
|
6507
|
+
}
|
|
6508
|
+
async function performAction(el, action, value) {
|
|
6509
|
+
try {
|
|
6510
|
+
if (action === "click") {
|
|
6511
|
+
await el.click({ timeout: 3000 });
|
|
6512
|
+
return true;
|
|
6513
|
+
}
|
|
6514
|
+
if (action === "fill") {
|
|
6515
|
+
await el.fill(value ?? "", { timeout: 3000 });
|
|
6516
|
+
await el.press("Enter", { timeout: 1500 }).catch(() => {
|
|
6517
|
+
return;
|
|
6518
|
+
});
|
|
6519
|
+
return true;
|
|
6520
|
+
}
|
|
6521
|
+
if (action === "press") {
|
|
6522
|
+
await el.press(value ?? "Enter", { timeout: 1500 });
|
|
6523
|
+
return true;
|
|
6524
|
+
}
|
|
6525
|
+
} catch {
|
|
6526
|
+
return false;
|
|
6527
|
+
}
|
|
6528
|
+
return false;
|
|
6529
|
+
}
|
|
6530
|
+
var GENERIC_TITLE_PATTERNS = [
|
|
6531
|
+
/^p[áa]gina de produto/i,
|
|
6532
|
+
/^product page/i,
|
|
6533
|
+
/^carregando/i,
|
|
6534
|
+
/^loading/i,
|
|
6535
|
+
/^undefined/i,
|
|
6536
|
+
/^home$/i
|
|
6537
|
+
];
|
|
6538
|
+
function looksGeneric(s) {
|
|
6539
|
+
const t = s.trim();
|
|
6540
|
+
return GENERIC_TITLE_PATTERNS.some((re) => re.test(t));
|
|
6541
|
+
}
|
|
6542
|
+
async function extractProductTitle(page) {
|
|
6543
|
+
await page.waitForSelector("h1", { timeout: 2500, state: "attached" }).catch(() => {
|
|
6544
|
+
return;
|
|
6545
|
+
});
|
|
6546
|
+
const visibleSelectors = [
|
|
6547
|
+
"main h1",
|
|
6548
|
+
"h1[class*='product' i]",
|
|
6549
|
+
"h1.product-title",
|
|
6550
|
+
"[itemprop='name'][data-product-name]",
|
|
6551
|
+
"[data-product-name]",
|
|
6552
|
+
"[data-fs-product-title]",
|
|
6553
|
+
"[itemprop='name']",
|
|
6554
|
+
".vtex-store-components-3-x-productNameContainer",
|
|
6555
|
+
"h1"
|
|
6556
|
+
];
|
|
6557
|
+
for (const sel of visibleSelectors) {
|
|
6558
|
+
try {
|
|
6559
|
+
const el = page.locator(sel).first();
|
|
6560
|
+
const visible = await withCap(el.isVisible({ timeout: 250 }).catch(() => false), 400, false);
|
|
6561
|
+
if (!visible)
|
|
6562
|
+
continue;
|
|
6563
|
+
const text = await withCap(el.innerText().catch(() => ""), 500, "");
|
|
6564
|
+
const clean = text.trim();
|
|
6565
|
+
if (clean.length > 3 && !looksGeneric(clean))
|
|
6566
|
+
return clean;
|
|
6567
|
+
} catch {}
|
|
6568
|
+
}
|
|
6569
|
+
try {
|
|
6570
|
+
const jsonLdName = await withCap(page.evaluate(() => {
|
|
6571
|
+
const scripts = Array.from(document.querySelectorAll('script[type="application/ld+json"]'));
|
|
6572
|
+
for (const s of scripts) {
|
|
6573
|
+
try {
|
|
6574
|
+
const data = JSON.parse(s.textContent ?? "{}");
|
|
6575
|
+
const items = Array.isArray(data) ? data : [data];
|
|
6576
|
+
for (const item of items) {
|
|
6577
|
+
const product = item?.["@graph"]?.find?.((x) => x?.["@type"] === "Product") ?? item;
|
|
6578
|
+
if (product?.["@type"] === "Product" && typeof product.name === "string") {
|
|
6579
|
+
return product.name;
|
|
6580
|
+
}
|
|
6581
|
+
}
|
|
6582
|
+
} catch {}
|
|
6583
|
+
}
|
|
6584
|
+
return null;
|
|
6585
|
+
}), 1000, null);
|
|
6586
|
+
if (jsonLdName && jsonLdName.trim().length > 3 && !looksGeneric(jsonLdName)) {
|
|
6587
|
+
return jsonLdName.trim();
|
|
6588
|
+
}
|
|
6589
|
+
} catch {}
|
|
6590
|
+
try {
|
|
6591
|
+
const og = await withCap(page.locator("meta[property='og:title']").first().getAttribute("content").catch(() => null), 500, null);
|
|
6592
|
+
if (og && og.trim().length > 3 && !looksGeneric(og))
|
|
6593
|
+
return og.trim();
|
|
6594
|
+
} catch {}
|
|
6595
|
+
const docTitle = await withCap(page.title().catch(() => ""), 500, "");
|
|
6596
|
+
if (docTitle.trim().length > 3 && !looksGeneric(docTitle))
|
|
6597
|
+
return docTitle.trim();
|
|
6598
|
+
return null;
|
|
6599
|
+
}
|
|
6600
|
+
async function isCartUiVisible(page) {
|
|
6601
|
+
return firstVisible(page, [
|
|
6602
|
+
"[role='dialog']:visible",
|
|
6603
|
+
"[aria-modal='true']:visible",
|
|
6604
|
+
"[data-minicart][aria-hidden='false']",
|
|
6605
|
+
"[data-minicart-open]",
|
|
6606
|
+
".minicart--open",
|
|
6607
|
+
".minicart-drawer:not([hidden])",
|
|
6608
|
+
"[class*='minicart'][class*='open']",
|
|
6609
|
+
"[class*='cart-drawer'][class*='open']",
|
|
6610
|
+
"[class*='drawer-cart']:visible"
|
|
6611
|
+
]);
|
|
6612
|
+
}
|
|
6613
|
+
async function isCartRevealed(page, expectedProductTitle) {
|
|
6614
|
+
if (expectedProductTitle) {
|
|
6615
|
+
const v = await validateCartContainsTitleQuick(page, expectedProductTitle);
|
|
6616
|
+
if (v)
|
|
6617
|
+
return `title-found:${v}`;
|
|
6618
|
+
}
|
|
6619
|
+
return isCartUiVisible(page);
|
|
6620
|
+
}
|
|
6621
|
+
async function validateCartContainsTitleQuick(page, expectedTitle) {
|
|
6622
|
+
const quickSelectors = [
|
|
6623
|
+
"[role='dialog']",
|
|
6624
|
+
"[class*='minicart' i]",
|
|
6625
|
+
"[class*='cart' i]",
|
|
6626
|
+
"[class*='checkout' i]",
|
|
6627
|
+
"#cart-fixed",
|
|
6628
|
+
"table.cart-items"
|
|
6629
|
+
];
|
|
6630
|
+
for (const scope of quickSelectors) {
|
|
6631
|
+
try {
|
|
6632
|
+
const scopeLoc = page.locator(scope);
|
|
6633
|
+
if (await withCap(scopeLoc.count(), 800, 0) === 0)
|
|
6634
|
+
continue;
|
|
6635
|
+
const text = await withCap(scopeLoc.first().innerText().catch(() => ""), 800, "");
|
|
6636
|
+
if (text && titlesMatch(text, expectedTitle))
|
|
6637
|
+
return scope;
|
|
6638
|
+
} catch {}
|
|
6639
|
+
}
|
|
6640
|
+
return null;
|
|
6641
|
+
}
|
|
6642
|
+
async function dismissOverlays(page, ctx) {
|
|
6643
|
+
const overlaySelectors = [
|
|
6644
|
+
"[class*='cookie' i][class*='banner' i]",
|
|
6645
|
+
"[class*='cookie' i][class*='consent' i]",
|
|
6646
|
+
"[id*='cookie' i][class*='banner' i]",
|
|
6647
|
+
"[role='alertdialog']:visible",
|
|
6648
|
+
"[class*='toast' i]:visible",
|
|
6649
|
+
"[class*='snackbar' i]:visible",
|
|
6650
|
+
"[class*='added-to-cart' i]:visible",
|
|
6651
|
+
"[class*='product-added' i]:visible"
|
|
6652
|
+
];
|
|
6653
|
+
let dismissedAny = false;
|
|
6654
|
+
for (const sel of overlaySelectors) {
|
|
6655
|
+
try {
|
|
6656
|
+
const overlay = page.locator(sel).first();
|
|
6657
|
+
if (!await withCap(overlay.isVisible({ timeout: 200 }).catch(() => false), 400, false))
|
|
6658
|
+
continue;
|
|
6659
|
+
const closer = overlay.locator("button[aria-label*='close' i], button[aria-label*='fechar' i], button[class*='close' i], [data-close], [aria-label='Close']").first();
|
|
6660
|
+
if (await withCap(closer.isVisible({ timeout: 200 }).catch(() => false), 400, false)) {
|
|
6661
|
+
await closer.click({ timeout: 1500 }).catch(() => {
|
|
6662
|
+
return;
|
|
6663
|
+
});
|
|
6664
|
+
dismissedAny = true;
|
|
6665
|
+
continue;
|
|
6666
|
+
}
|
|
6667
|
+
await page.keyboard.press("Escape").catch(() => {
|
|
6668
|
+
return;
|
|
6669
|
+
});
|
|
6670
|
+
dismissedAny = true;
|
|
6671
|
+
} catch {}
|
|
6672
|
+
}
|
|
6673
|
+
if (dismissedAny) {
|
|
6674
|
+
dlog2(ctx, " openMinicart: dismissed overlay(s) before interacting");
|
|
6675
|
+
await page.waitForTimeout(500);
|
|
6676
|
+
}
|
|
6677
|
+
}
|
|
6678
|
+
async function waitForCartHydration(page) {
|
|
6679
|
+
await Promise.race([
|
|
6680
|
+
page.waitForResponse((r) => /\/api\/checkout\/pub\/orderForm|orderForm|cart\/api/i.test(r.url()) && r.ok(), { timeout: 8000 }).catch(() => {
|
|
6681
|
+
return;
|
|
6682
|
+
}),
|
|
6683
|
+
page.waitForSelector(".cart-items, [class*='cart-item' i], #cart-fixed .item, [data-cart-item]", { timeout: 8000 }).catch(() => {
|
|
6684
|
+
return;
|
|
6685
|
+
})
|
|
6686
|
+
]);
|
|
6687
|
+
await page.waitForTimeout(800);
|
|
6688
|
+
}
|
|
6689
|
+
async function openMinicart(page, trigger, ctx, expectedProductTitle) {
|
|
6690
|
+
const beforeUrl = page.url();
|
|
6691
|
+
await dismissOverlays(page, ctx);
|
|
6692
|
+
await page.waitForTimeout(800);
|
|
6693
|
+
const alreadyOpen = await isCartRevealed(page, expectedProductTitle);
|
|
6694
|
+
if (alreadyOpen) {
|
|
6695
|
+
dlog2(ctx, ` openMinicart: already-open (matched ${alreadyOpen})`);
|
|
6696
|
+
return { method: "already-open", url: beforeUrl, visibleMarker: alreadyOpen };
|
|
6697
|
+
}
|
|
6698
|
+
const triggerHref = await trigger.locator.getAttribute("href").catch(() => null);
|
|
6699
|
+
const hrefHasCartTarget = !!triggerHref && /\/(checkout|cart|carrinho)/i.test(triggerHref);
|
|
6700
|
+
if (ctx.viewport === "desktop") {
|
|
6701
|
+
dlog2(ctx, ` openMinicart: trying hover first on ${trigger.selector}${triggerHref ? ` (href=${triggerHref})` : ""}`);
|
|
6702
|
+
await trigger.locator.hover({ timeout: 3000 }).catch(() => {
|
|
6703
|
+
return;
|
|
6704
|
+
});
|
|
6705
|
+
await page.waitForTimeout(1500);
|
|
6706
|
+
const hoverOpened = await isCartRevealed(page, expectedProductTitle);
|
|
6707
|
+
if (hoverOpened) {
|
|
6708
|
+
dlog2(ctx, ` openMinicart: hover opened drawer (${hoverOpened})`);
|
|
6709
|
+
return { method: "hover", url: page.url(), visibleMarker: hoverOpened };
|
|
6710
|
+
}
|
|
6711
|
+
}
|
|
6712
|
+
if (ctx.viewport === "mobile") {
|
|
6713
|
+
dlog2(ctx, ` openMinicart: trying tap (mobile) on ${trigger.selector}`);
|
|
6714
|
+
await Promise.all([
|
|
6715
|
+
page.waitForURL((url) => url.toString() !== beforeUrl, { timeout: 4000 }).catch(() => {
|
|
6716
|
+
return;
|
|
6717
|
+
}),
|
|
6718
|
+
trigger.locator.tap({ timeout: 4000 }).catch(() => {
|
|
6719
|
+
return;
|
|
6720
|
+
})
|
|
6721
|
+
]);
|
|
6722
|
+
if (page.url() !== beforeUrl) {
|
|
6723
|
+
await page.waitForLoadState("load", { timeout: 8000 }).catch(() => {
|
|
6724
|
+
return;
|
|
6725
|
+
});
|
|
6726
|
+
await waitForCartHydration(page);
|
|
6727
|
+
dlog2(ctx, ` openMinicart: tap navigated → ${page.url()} (settled)`);
|
|
6728
|
+
return { method: "click-navigate", url: page.url(), visibleMarker: null };
|
|
6729
|
+
}
|
|
6730
|
+
await page.waitForTimeout(800);
|
|
6731
|
+
const tapOpened = await isCartRevealed(page, expectedProductTitle);
|
|
6732
|
+
if (tapOpened) {
|
|
6733
|
+
dlog2(ctx, ` openMinicart: tap opened drawer (${tapOpened})`);
|
|
6734
|
+
return { method: "click", url: page.url(), visibleMarker: tapOpened };
|
|
6735
|
+
}
|
|
6736
|
+
}
|
|
6737
|
+
dlog2(ctx, ` openMinicart: trying force-click on ${trigger.selector}${triggerHref ? ` (href=${triggerHref})` : ""}`);
|
|
6738
|
+
await Promise.all([
|
|
6739
|
+
page.waitForURL((url) => url.toString() !== beforeUrl, { timeout: 4000 }).catch(() => {
|
|
6740
|
+
return;
|
|
6741
|
+
}),
|
|
6742
|
+
trigger.locator.click({ force: true, timeout: 4000 }).catch(() => {
|
|
6743
|
+
return;
|
|
6744
|
+
})
|
|
6745
|
+
]);
|
|
6746
|
+
const afterClickUrl = page.url();
|
|
6747
|
+
if (afterClickUrl !== beforeUrl) {
|
|
6748
|
+
await page.waitForLoadState("load", { timeout: 8000 }).catch(() => {
|
|
6749
|
+
return;
|
|
6750
|
+
});
|
|
6751
|
+
await waitForCartHydration(page);
|
|
6752
|
+
dlog2(ctx, ` openMinicart: click navigated → ${page.url()} (settled)`);
|
|
6753
|
+
return { method: "click-navigate", url: page.url(), visibleMarker: null };
|
|
6754
|
+
}
|
|
6755
|
+
await page.waitForTimeout(1500);
|
|
6756
|
+
const clickOpened = await isCartRevealed(page, expectedProductTitle);
|
|
6757
|
+
if (clickOpened) {
|
|
6758
|
+
dlog2(ctx, ` openMinicart: click opened drawer (${clickOpened})`);
|
|
6759
|
+
return { method: "click", url: afterClickUrl, visibleMarker: clickOpened };
|
|
6760
|
+
}
|
|
6761
|
+
if (ctx.viewport !== "desktop") {
|
|
6762
|
+
dlog2(ctx, ` openMinicart: click didn't reveal cart, trying hover (mobile)`);
|
|
6763
|
+
await trigger.locator.hover({ timeout: 3000 }).catch(() => {
|
|
6764
|
+
return;
|
|
6765
|
+
});
|
|
6766
|
+
await page.waitForTimeout(1500);
|
|
6767
|
+
const hoverOpened = await isCartRevealed(page, expectedProductTitle);
|
|
6768
|
+
if (hoverOpened) {
|
|
6769
|
+
dlog2(ctx, ` openMinicart: hover opened drawer (${hoverOpened})`);
|
|
6770
|
+
return { method: "hover", url: page.url(), visibleMarker: hoverOpened };
|
|
6771
|
+
}
|
|
6772
|
+
}
|
|
6773
|
+
if (hrefHasCartTarget && triggerHref) {
|
|
6774
|
+
const targetUrl = (() => {
|
|
6775
|
+
try {
|
|
6776
|
+
return new URL(triggerHref, page.url()).toString();
|
|
6777
|
+
} catch {
|
|
6778
|
+
return null;
|
|
6779
|
+
}
|
|
6780
|
+
})();
|
|
6781
|
+
if (targetUrl) {
|
|
6782
|
+
dlog2(ctx, ` openMinicart: all interactive strategies failed; navigating directly to ${targetUrl}`);
|
|
6783
|
+
await page.goto(targetUrl, { waitUntil: "load", timeout: 15000 }).catch(() => {
|
|
6784
|
+
return;
|
|
6785
|
+
});
|
|
6786
|
+
await waitForCartHydration(page);
|
|
6787
|
+
if (page.url() !== beforeUrl) {
|
|
6788
|
+
dlog2(ctx, ` openMinicart: goto fallback landed on ${page.url()}`);
|
|
6789
|
+
return { method: "click-navigate", url: page.url(), visibleMarker: null };
|
|
6790
|
+
}
|
|
6791
|
+
}
|
|
6792
|
+
}
|
|
6793
|
+
dlog2(ctx, " openMinicart: failed — no cart revealed by hover/click/goto");
|
|
6794
|
+
return { method: "failed", url: page.url(), visibleMarker: null };
|
|
6795
|
+
}
|
|
6796
|
+
function normalizeTitle(s) {
|
|
6797
|
+
return s.toLowerCase().replace(/[®©™]/g, "").replace(/[^\p{L}\p{N}\s]+/gu, " ").replace(/\s+/g, " ").trim();
|
|
6798
|
+
}
|
|
6799
|
+
function titlesMatch(observed, expected) {
|
|
6800
|
+
const o = normalizeTitle(observed);
|
|
6801
|
+
const e = normalizeTitle(expected);
|
|
6802
|
+
if (!o || !e)
|
|
6803
|
+
return false;
|
|
6804
|
+
if (o === e)
|
|
6805
|
+
return true;
|
|
6806
|
+
if (e.length >= 12 && o.includes(e))
|
|
6807
|
+
return true;
|
|
6808
|
+
if (o.length >= 12 && e.includes(o))
|
|
6809
|
+
return true;
|
|
6810
|
+
return false;
|
|
6811
|
+
}
|
|
6812
|
+
async function validateCartContainsTitle(page, expectedTitle, ctx) {
|
|
6813
|
+
const titleSelectors = [
|
|
6814
|
+
"[data-cart-item-name]",
|
|
6815
|
+
"[data-cart-item] [class*='title' i]",
|
|
6816
|
+
"[data-cart-item] [class*='name' i]",
|
|
6817
|
+
"[class*='cart' i] [data-product-name]",
|
|
6818
|
+
"[role='dialog'] [data-product-name]",
|
|
6819
|
+
"[class*='checkout' i] [data-product-name]",
|
|
6820
|
+
"[class*='minicart' i] [data-product-name]",
|
|
6821
|
+
"[data-testid='cart-item-name']",
|
|
6822
|
+
"[data-testid='product-name']",
|
|
6823
|
+
"[role='dialog'] li [class*='product' i]",
|
|
6824
|
+
"[role='dialog'] li [class*='name' i]",
|
|
6825
|
+
"[role='dialog'] li [class*='title' i]",
|
|
6826
|
+
"[role='dialog'] a[href*='/p']",
|
|
6827
|
+
"[class*='minicart' i] [class*='item' i] [class*='name' i]",
|
|
6828
|
+
"[class*='minicart' i] [class*='item' i] [class*='title' i]",
|
|
6829
|
+
"[class*='cart-item' i] [class*='name' i]",
|
|
6830
|
+
"[class*='cart-item' i] [class*='title' i]",
|
|
6831
|
+
"[class*='checkout' i] [class*='product' i] [class*='name' i]",
|
|
6832
|
+
".vtex-minicart-2-x-itemNameContainer",
|
|
6833
|
+
".vtex-checkout-summary-0-x-itemName",
|
|
6834
|
+
".product-name",
|
|
6835
|
+
".item-name",
|
|
6836
|
+
"a.product-name",
|
|
6837
|
+
".cart-items .item-name",
|
|
6838
|
+
"tr.product-item .item-name",
|
|
6839
|
+
"tr.cart-item .item-name",
|
|
6840
|
+
"table.cart-items td a",
|
|
6841
|
+
"#cart-fixed .item .product-name",
|
|
6842
|
+
"#cart-fixed .item-name",
|
|
6843
|
+
".cart-fixed .item-name",
|
|
6844
|
+
".cart-fixed .product-name",
|
|
6845
|
+
"#cart-fixed li a",
|
|
6846
|
+
"#minicart-content .item-name",
|
|
6847
|
+
"[data-fs-cart-item-summary-title]",
|
|
6848
|
+
"[data-fs-cart-item-image] + * a",
|
|
6849
|
+
"[class*='cart' i] a[href*='/p']",
|
|
6850
|
+
"[class*='checkout' i] a[href*='/p']"
|
|
6851
|
+
];
|
|
6852
|
+
const sweepTitles = async () => {
|
|
6853
|
+
const observed2 = [];
|
|
6854
|
+
for (const sel of titleSelectors) {
|
|
6855
|
+
try {
|
|
6856
|
+
const loc = page.locator(sel);
|
|
6857
|
+
const count = await withCap(loc.count(), 1000, 0);
|
|
6858
|
+
const limit = Math.min(count, 10);
|
|
6859
|
+
for (let i = 0;i < limit; i++) {
|
|
6860
|
+
const el = loc.nth(i);
|
|
6861
|
+
const visible = await withCap(el.isVisible({ timeout: 200 }).catch(() => false), 400, false);
|
|
6862
|
+
if (!visible)
|
|
6863
|
+
continue;
|
|
6864
|
+
const text = await withCap(el.innerText().catch(() => ""), 500, "");
|
|
6865
|
+
const clean = text.trim().slice(0, 200);
|
|
6866
|
+
if (clean.length > 2)
|
|
6867
|
+
observed2.push(clean);
|
|
6868
|
+
}
|
|
6869
|
+
if (observed2.length > 0)
|
|
6870
|
+
return observed2;
|
|
6871
|
+
} catch {}
|
|
6872
|
+
}
|
|
6873
|
+
return observed2;
|
|
6874
|
+
};
|
|
6875
|
+
let observed = await sweepTitles();
|
|
6876
|
+
if (observed.length === 0) {
|
|
6877
|
+
dlog2(ctx, " validateCartContainsTitle: 0 titles on first pass, retrying after 2s");
|
|
6878
|
+
await page.waitForTimeout(2000);
|
|
6879
|
+
observed = await sweepTitles();
|
|
6880
|
+
}
|
|
6881
|
+
dlog2(ctx, ` validateCartContainsTitle: observed ${observed.length} titles`);
|
|
6882
|
+
if (observed.length === 0) {
|
|
6883
|
+
return { found: false, observedTitles: [], method: "none" };
|
|
6884
|
+
}
|
|
6885
|
+
const found = observed.some((o) => titlesMatch(o, expectedTitle));
|
|
6886
|
+
return { found, observedTitles: observed, method: "selector" };
|
|
6887
|
+
}
|
|
6888
|
+
async function detectEmptyCartBanner(page) {
|
|
6889
|
+
const bannerSelectors = [
|
|
6890
|
+
":text-matches('carrinho.*vazio', 'i')",
|
|
6891
|
+
":text-matches('seu carrinho está vazio', 'i')",
|
|
6892
|
+
":text-matches('empty cart', 'i')",
|
|
6893
|
+
":text-matches('cart is empty', 'i')",
|
|
6894
|
+
":text-matches('nenhum item.*carrinho', 'i')",
|
|
6895
|
+
"[class*='empty' i][class*='cart' i]",
|
|
6896
|
+
"[class*='cart-empty' i]"
|
|
6897
|
+
];
|
|
6898
|
+
for (const sel of bannerSelectors) {
|
|
6899
|
+
try {
|
|
6900
|
+
const loc = page.locator(sel).first();
|
|
6901
|
+
if (await withCap(loc.isVisible({ timeout: 300 }).catch(() => false), 500, false)) {
|
|
6902
|
+
const text = await withCap(loc.innerText().catch(() => ""), 500, "");
|
|
6903
|
+
if (text.trim())
|
|
6904
|
+
return text.trim().slice(0, 120);
|
|
6905
|
+
}
|
|
6906
|
+
} catch {}
|
|
6907
|
+
}
|
|
6908
|
+
return null;
|
|
6909
|
+
}
|
|
5701
6910
|
|
|
5702
6911
|
// src/ignore/parser.ts
|
|
5703
6912
|
import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
|
|
@@ -5807,6 +7016,51 @@ function detectFromHtml(html) {
|
|
|
5807
7016
|
return "custom";
|
|
5808
7017
|
}
|
|
5809
7018
|
|
|
7019
|
+
// src/learned/promote.ts
|
|
7020
|
+
function promoteStepsFromFlow(learned, platform, host, flow) {
|
|
7021
|
+
let promoted = 0;
|
|
7022
|
+
let deprecated = 0;
|
|
7023
|
+
let recorded = 0;
|
|
7024
|
+
for (const step of flow.steps ?? []) {
|
|
7025
|
+
if (!step.selectorKey || !step.usedSelector)
|
|
7026
|
+
continue;
|
|
7027
|
+
if (!isReusableSelector(step.usedSelector))
|
|
7028
|
+
continue;
|
|
7029
|
+
const parsed = SelectorKey.safeParse(step.selectorKey);
|
|
7030
|
+
if (!parsed.success)
|
|
7031
|
+
continue;
|
|
7032
|
+
const key = parsed.data;
|
|
7033
|
+
if (step.recoveredByLlm) {
|
|
7034
|
+
promoteFromLlm(learned, platform, key, step.usedSelector, host);
|
|
7035
|
+
promoted++;
|
|
7036
|
+
} else if (step.status === "ok") {
|
|
7037
|
+
recordSuccess(learned, platform, key, step.usedSelector, host);
|
|
7038
|
+
recorded++;
|
|
7039
|
+
} else if (step.status === "failed") {
|
|
7040
|
+
const wasDeprecated = isAlreadyDeprecated(learned, platform, key, step.usedSelector);
|
|
7041
|
+
const after = recordFailure(learned, platform, key, step.usedSelector, host);
|
|
7042
|
+
if (!wasDeprecated && after?.deprecated)
|
|
7043
|
+
deprecated++;
|
|
7044
|
+
}
|
|
7045
|
+
}
|
|
7046
|
+
return { promoted, deprecated, recorded };
|
|
7047
|
+
}
|
|
7048
|
+
function isReusableSelector(s) {
|
|
7049
|
+
if (s.includes("→"))
|
|
7050
|
+
return false;
|
|
7051
|
+
if (s.includes(" ~ "))
|
|
7052
|
+
return false;
|
|
7053
|
+
return s.trim().length > 0;
|
|
7054
|
+
}
|
|
7055
|
+
function isAlreadyDeprecated(lib, platform, key, selector) {
|
|
7056
|
+
const platformEntries = lib.platforms[platform];
|
|
7057
|
+
if (!platformEntries)
|
|
7058
|
+
return false;
|
|
7059
|
+
const entries = platformEntries[key] ?? [];
|
|
7060
|
+
const entry = entries.find((e) => e.selector === selector);
|
|
7061
|
+
return !!entry?.deprecated;
|
|
7062
|
+
}
|
|
7063
|
+
|
|
5810
7064
|
// src/llm/discover-selectors.ts
|
|
5811
7065
|
import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "node:fs";
|
|
5812
7066
|
import { join as join6 } from "node:path";
|
|
@@ -5832,26 +7086,26 @@ var DISCOVER_SELECTORS_TOOL = {
|
|
|
5832
7086
|
},
|
|
5833
7087
|
minicart_trigger: {
|
|
5834
7088
|
type: "string",
|
|
5835
|
-
description:
|
|
7089
|
+
description: `CSS selector for the CART ICON / SACOLA icon in the HEADER that opens the mini-cart drawer or navigates to the cart page when clicked (e.g. '[data-minicart-trigger]', or 'header a[aria-label*="sacola" i]', or 'header a[href*="cart"]'). This is the icon ALWAYS visible in the page header.`
|
|
5836
7090
|
},
|
|
5837
7091
|
cep_input_pdp: {
|
|
5838
7092
|
type: "string",
|
|
5839
|
-
description: "CSS selector for the CEP / zipcode <input> on a PDP. Empty string if the PDP has no shipping calculator."
|
|
7093
|
+
description: "CSS selector for the CEP / zipcode <input> on a PDP — the input where the customer types their ADDRESS POSTAL CODE to calculate shipping. Placeholder is typically 'CEP', 'Digite seu CEP', 'Cód. postal'. Do NOT return a coupon input ('Digite o cupom') or email/newsletter field. Empty string if the PDP has no shipping calculator."
|
|
5840
7094
|
},
|
|
5841
7095
|
cep_input_cart: {
|
|
5842
7096
|
type: "string",
|
|
5843
|
-
description: "CSS selector for the CEP / zipcode <input> inside the mini-cart
|
|
7097
|
+
description: "CSS selector for the CEP / zipcode <input> inside the mini-cart drawer or cart page — for shipping calculation. Same rules: ADDRESS postal code, NOT coupon, NOT email. Empty string if cart has no shipping calculator."
|
|
5844
7098
|
},
|
|
5845
7099
|
checkout_button: {
|
|
5846
7100
|
type: "string",
|
|
5847
|
-
description:
|
|
7101
|
+
description: "CSS selector for the FINAL 'Go to checkout' / 'Finalizar compra' / 'Finalizar' button INSIDE the mini-cart drawer or cart page — the one user clicks AFTER reviewing cart items to proceed to checkout/payment. **IMPORTANT**: this is DIFFERENT from `minicart_trigger` (which is the cart ICON in the header). The checkout button is typically a big colored button at the bottom of the cart drawer or cart page. If you cannot see it on the home page (which is common — it's only rendered after add-to-cart), return EMPTY STRING. NEVER return the same selector as `minicart_trigger`."
|
|
5848
7102
|
},
|
|
5849
7103
|
reasoning: {
|
|
5850
7104
|
type: "string",
|
|
5851
7105
|
description: "1-2 sentence rationale describing what was inferred from the markup."
|
|
5852
7106
|
}
|
|
5853
7107
|
},
|
|
5854
|
-
required: ["category_link", "product_card", "buy_button", "minicart_trigger"
|
|
7108
|
+
required: ["category_link", "product_card", "buy_button", "minicart_trigger"]
|
|
5855
7109
|
}
|
|
5856
7110
|
};
|
|
5857
7111
|
var SYSTEM_PROMPT = `
|
|
@@ -5888,7 +7142,17 @@ REGRAS:
|
|
|
5888
7142
|
4. Para "cep_input_pdp" e "cep_input_cart": retorne string vazia se não existir esse recurso na plataforma.
|
|
5889
7143
|
Não invente.
|
|
5890
7144
|
|
|
5891
|
-
5.
|
|
7145
|
+
5. **NUNCA confunda \`minicart_trigger\` (ícone do carrinho no HEADER) com \`checkout_button\`
|
|
7146
|
+
(botão "Finalizar" DENTRO do drawer ou na página de cart).** Se você só vê o ícone do
|
|
7147
|
+
carrinho na home e não consegue ver o botão de checkout (caso comum — ele só renderiza
|
|
7148
|
+
depois de add-to-cart), retorne STRING VAZIA pra \`checkout_button\`. NUNCA retorne o
|
|
7149
|
+
mesmo seletor para os dois — isso quebra o fluxo de teste.
|
|
7150
|
+
|
|
7151
|
+
6. Para CEP: \`cep_input_pdp\` e \`cep_input_cart\` são pra ENDEREÇO (CEP / Postal Code).
|
|
7152
|
+
NÃO confunda com input de CUPOM ('Digite o cupom', 'Insira código', 'Promo code') —
|
|
7153
|
+
cupom é desconto, não frete.
|
|
7154
|
+
|
|
7155
|
+
7. Sempre teste mentalmente: "este seletor pegaria o elemento certo na home E também em PDPs/cart?"
|
|
5892
7156
|
|
|
5893
7157
|
Responda SEMPRE via tool_use report_selectors. Não escreva texto livre fora da tool call.
|
|
5894
7158
|
`.trim();
|
|
@@ -5962,12 +7226,24 @@ ${compacted}
|
|
|
5962
7226
|
cepInputCart: emptyToUndef(input.cep_input_cart),
|
|
5963
7227
|
checkoutButton: emptyToUndef(input.checkout_button)
|
|
5964
7228
|
};
|
|
7229
|
+
if (selectors.checkoutButton && selectors.minicartTrigger && selectorsLikelyConflict(selectors.checkoutButton, selectors.minicartTrigger)) {
|
|
7230
|
+
console.warn(`[discover-selectors] checkout_button == minicart_trigger (\`${selectors.checkoutButton}\`); dropping to avoid step-9 misclick`);
|
|
7231
|
+
selectors.checkoutButton = undefined;
|
|
7232
|
+
}
|
|
5965
7233
|
if (!existsSync6(cacheDir))
|
|
5966
7234
|
mkdirSync4(cacheDir, { recursive: true });
|
|
5967
7235
|
writeFileSync5(cachePath, `${JSON.stringify(selectors, null, 2)}
|
|
5968
7236
|
`, "utf8");
|
|
5969
7237
|
return selectors;
|
|
5970
7238
|
}
|
|
7239
|
+
function selectorsLikelyConflict(a, b) {
|
|
7240
|
+
if (a === b)
|
|
7241
|
+
return true;
|
|
7242
|
+
const norm = (s) => s.replace(/\s+/g, "").toLowerCase();
|
|
7243
|
+
const na = norm(a);
|
|
7244
|
+
const nb = norm(b);
|
|
7245
|
+
return na.includes(nb) || nb.includes(na);
|
|
7246
|
+
}
|
|
5971
7247
|
function emptyToUndef(v) {
|
|
5972
7248
|
if (v == null)
|
|
5973
7249
|
return;
|
|
@@ -5987,11 +7263,12 @@ var STEP_LABELS = {
|
|
|
5987
7263
|
"visit-home": "1. visit-home",
|
|
5988
7264
|
"navigate-plp": "2. navigate-plp",
|
|
5989
7265
|
"enter-pdp": "3. enter-pdp",
|
|
5990
|
-
"
|
|
5991
|
-
"
|
|
5992
|
-
"
|
|
5993
|
-
"
|
|
5994
|
-
"
|
|
7266
|
+
"select-variant": "4. select-variant",
|
|
7267
|
+
"shipping-calc-pdp": "5. shipping-calc-pdp",
|
|
7268
|
+
"add-to-cart": "6. add-to-cart",
|
|
7269
|
+
"open-minicart": "7. open-minicart",
|
|
7270
|
+
"shipping-calc-cart": "8. shipping-calc-cart",
|
|
7271
|
+
"go-checkout": "9. go-checkout"
|
|
5995
7272
|
};
|
|
5996
7273
|
var CRITICAL_STEPS = new Set([
|
|
5997
7274
|
"visit-home",
|
|
@@ -6120,6 +7397,39 @@ async function journeyCommand(opts) {
|
|
|
6120
7397
|
}
|
|
6121
7398
|
if (!opts.json)
|
|
6122
7399
|
console.log("");
|
|
7400
|
+
const learnedBefore = JSON.stringify(learned);
|
|
7401
|
+
const prodHost = safeHost2(opts.prod);
|
|
7402
|
+
let totalPromoted = 0;
|
|
7403
|
+
let totalDeprecated = 0;
|
|
7404
|
+
let totalRecorded = 0;
|
|
7405
|
+
for (const cap of flowCaptures) {
|
|
7406
|
+
if (cap.side !== "prod")
|
|
7407
|
+
continue;
|
|
7408
|
+
const result = promoteStepsFromFlow(learned, platform, prodHost, cap);
|
|
7409
|
+
totalPromoted += result.promoted;
|
|
7410
|
+
totalDeprecated += result.deprecated;
|
|
7411
|
+
totalRecorded += result.recorded;
|
|
7412
|
+
}
|
|
7413
|
+
if (JSON.stringify(learned) !== learnedBefore) {
|
|
7414
|
+
try {
|
|
7415
|
+
saveLearned(learned);
|
|
7416
|
+
if (!opts.json) {
|
|
7417
|
+
const bits = [];
|
|
7418
|
+
if (totalPromoted > 0)
|
|
7419
|
+
bits.push(`${totalPromoted} promovido(s) via LLM`);
|
|
7420
|
+
if (totalRecorded > 0)
|
|
7421
|
+
bits.push(`${totalRecorded} reforçado(s)`);
|
|
7422
|
+
if (totalDeprecated > 0)
|
|
7423
|
+
bits.push(`${totalDeprecated} depreciado(s)`);
|
|
7424
|
+
if (bits.length > 0)
|
|
7425
|
+
console.log(chalk6.dim(` learned-selectors atualizado: ${bits.join(", ")}`));
|
|
7426
|
+
}
|
|
7427
|
+
} catch (err) {
|
|
7428
|
+
if (!opts.json) {
|
|
7429
|
+
console.warn(chalk6.yellow(` warn: failed to persist learned-selectors: ${err.message}`));
|
|
7430
|
+
}
|
|
7431
|
+
}
|
|
7432
|
+
}
|
|
6123
7433
|
const rows = buildRows(flowCaptures, viewports);
|
|
6124
7434
|
const failures = collectFailures(rows);
|
|
6125
7435
|
writeRunReportJson(paths.runDir, buildJourneyRun(opts, runId, flowCaptures, failures));
|
|
@@ -6477,6 +7787,13 @@ function renderSummaryBlock(rows, failures) {
|
|
|
6477
7787
|
function escapeHtml(s) {
|
|
6478
7788
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
6479
7789
|
}
|
|
7790
|
+
function safeHost2(url) {
|
|
7791
|
+
try {
|
|
7792
|
+
return new URL(url).hostname;
|
|
7793
|
+
} catch {
|
|
7794
|
+
return url;
|
|
7795
|
+
}
|
|
7796
|
+
}
|
|
6480
7797
|
|
|
6481
7798
|
// src/commands/learned.ts
|
|
6482
7799
|
import chalk7 from "chalk";
|
|
@@ -7795,6 +9112,7 @@ var STEP_LABELS2 = {
|
|
|
7795
9112
|
"visit-home": "Visitar home",
|
|
7796
9113
|
"navigate-plp": "Navegar para categoria (PLP)",
|
|
7797
9114
|
"enter-pdp": "Entrar em PDP",
|
|
9115
|
+
"select-variant": "Selecionar variante (tamanho/cor/quantidade)",
|
|
7798
9116
|
"shipping-calc-pdp": "Cálculo de frete na PDP",
|
|
7799
9117
|
"add-to-cart": "Adicionar ao carrinho",
|
|
7800
9118
|
"open-minicart": "Abrir minicart",
|
|
@@ -9689,21 +11007,9 @@ async function runCommand(rawOpts) {
|
|
|
9689
11007
|
for (const p of cap.pages)
|
|
9690
11008
|
allPageCaptures.push(p);
|
|
9691
11009
|
if (opts.learn !== false) {
|
|
9692
|
-
|
|
9693
|
-
|
|
9694
|
-
|
|
9695
|
-
const key = step.selectorKey;
|
|
9696
|
-
if (step.recoveredByLlm) {
|
|
9697
|
-
promoteFromLlm(learned, platform, key, step.usedSelector, prodHost);
|
|
9698
|
-
promotedCount++;
|
|
9699
|
-
} else if (step.status === "ok") {
|
|
9700
|
-
recordSuccess(learned, platform, key, step.usedSelector, prodHost);
|
|
9701
|
-
} else if (step.status === "failed") {
|
|
9702
|
-
const before = recordFailure(learned, platform, key, step.usedSelector, prodHost);
|
|
9703
|
-
if (before?.deprecated)
|
|
9704
|
-
deprecatedCount++;
|
|
9705
|
-
}
|
|
9706
|
-
}
|
|
11010
|
+
const result = promoteStepsFromFlow(learned, platform, prodHost, cap);
|
|
11011
|
+
promotedCount += result.promoted;
|
|
11012
|
+
deprecatedCount += result.deprecated;
|
|
9707
11013
|
}
|
|
9708
11014
|
}
|
|
9709
11015
|
await stopTracing(ctx, tracePath).catch(() => {
|