@decocms/parity 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/cli.js +200 -52
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.17.0](https://github.com/decocms/parity/compare/v0.16.0...v0.17.0) (2026-07-28)
11
+
12
+ ### Added
13
+
14
+ * **`open-minicart`/`add-to-cart` reveal-failure diagnostics fed to LLM recovery.** A poll-based step (waiting for the minicart drawer or an add-to-cart confirmation to appear) previously reported a bare "not found"/"no signal" on timeout, with no evidence of *why*. Failed polls now attach a structured `diagnostics` object to the step (`timedOut`, `budgetMs`, `elapsedMs`, `pollCount`, and a per-selector `probes` snapshot distinguishing "present in the DOM but stayed hidden" from "never matched at all") — surfaced in `report.json` and rendered in the HTML report's step cell. When `open-minicart` fails with this evidence available, parity now spends one LLM recovery attempt passing the diagnostics as concrete context (`RecoverInput.diagnostics`) instead of a bare failure, and the recovery prompt is told not to re-suggest a selector already confirmed present-but-hidden.
15
+ * New `cartRevealTimeoutMs` (issue #149 follow-up) tunes how long `waitForCartReveal` polls for the drawer before giving up. Defaults to 4000ms (8000ms on localhost dev servers, which are slower).
16
+
17
+ ### Fixed
18
+
19
+ * **`waitForCartReveal` polls instead of snapshotting once.** `openMinicart`'s reveal check (via `isCartRevealed`/Playwright `isVisible()`) was a one-shot read of the drawer's *current* state right after a fixed wait, not an actual wait. Drawers that reveal asynchronously — a CSS `allow-discrete` visibility/opacity transition, a data-gated render (react-query cart), or a slow click handler — can stay `visibility:hidden` (often positioned off-screen) for well over a second after the click, so the single snapshot landed inside the hidden window and wrongly reported the cart never opened, even with the correct selector, no overlay, and no console error. `openMinicart` now polls every 200ms up to an adaptive budget instead.
20
+ * **`open-minicart` re-checks for a blocking overlay before giving up.** `dismissOverlays` only ran once at the very top of `openMinicart`, before the trigger was clicked. Confirmed live against a production deploy: a newsletter popup can appear *after* the add-to-cart click and silently intercept the minicart-trigger click (`force: true` clicks whatever is topmost at the coordinates, not necessarily the intended target), so the drawer never opens even though the selector and reveal-polling logic are both correct. `openMinicart` now detects this structurally (mirroring `dismissBlockingOverlay`, issues #145/#146) and retries the click once after clearing it.
21
+
10
22
  ## [0.16.0](https://github.com/decocms/parity/compare/v0.15.1...v0.16.0) (2026-07-28)
11
23
 
12
24
  ### Added
package/dist/cli.js CHANGED
@@ -40340,6 +40340,14 @@ var REPORT_CSS = `
40340
40340
  color: var(--state-warn);
40341
40341
  font-style: italic;
40342
40342
  }
40343
+ .step-diagnostics {
40344
+ margin-top: 4px;
40345
+ font-size: 10px;
40346
+ color: var(--text-muted);
40347
+ border-left: 2px solid var(--state-warn);
40348
+ padding-left: 6px;
40349
+ }
40350
+ .step-diagnostics code { background: var(--surface-base); padding: 1px 4px; border-radius: 3px; }
40343
40351
 
40344
40352
  /* Network waterfall — per-page SVG bar chart. Issue #78. */
40345
40353
  .wf-page {
@@ -41154,6 +41162,18 @@ var StepCapture = z.object({
41154
41162
  loginValidation: z.object({
41155
41163
  stage: z.enum(["form-loaded", "submitted", "succeeded", "error-shown"]),
41156
41164
  errorMessage: z.string().optional()
41165
+ }).optional(),
41166
+ diagnostics: z.object({
41167
+ timedOut: z.boolean().optional(),
41168
+ budgetMs: z.number().optional(),
41169
+ elapsedMs: z.number().optional(),
41170
+ pollCount: z.number().optional(),
41171
+ probes: z.array(z.object({
41172
+ selector: z.string(),
41173
+ present: z.boolean(),
41174
+ visible: z.boolean()
41175
+ })).optional(),
41176
+ toastText: z.string().optional()
41157
41177
  }).optional()
41158
41178
  });
41159
41179
  var FlowCapture = z.object({
@@ -41436,7 +41456,8 @@ var ParityRc = z.object({
41436
41456
  serverFnFloodBudget: z.number().optional(),
41437
41457
  serverFnPattern: z.string().optional(),
41438
41458
  overlaySelectors: z.array(z.string()).optional(),
41439
- addToCartConfirmMs: z.number().optional()
41459
+ addToCartConfirmMs: z.number().optional(),
41460
+ cartRevealTimeoutMs: z.number().optional()
41440
41461
  });
41441
41462
  var ParityIgnore = z.object({
41442
41463
  ignoreSelectorsVisual: z.array(z.string()).default([]),
@@ -47516,12 +47537,26 @@ function renderStepCell(s, runDir) {
47516
47537
  const screenshot = s.screenshotPath ? `<a class="step-shot" href="${escapeHtml(relPath(runDir, s.screenshotPath))}" target="_blank" title="open screenshot">\uD83D\uDCF7</a>` : "";
47517
47538
  const selector = s.usedSelector ? `<div class="step-selector"><code>${escapeHtml(s.usedSelector)}</code></div>` : "";
47518
47539
  const note = s.note ? `<div class="step-note">${escapeHtml(s.note)}</div>` : "";
47540
+ const diagnostics = renderStepDiagnostics(s.diagnostics);
47519
47541
  return `<td class="step-cell ${statusClass}">
47520
47542
  <div class="step-cell-head"><span class="step-status">${s.status}</span>${screenshot}<span class="dim">${s.durationMs}ms</span></div>
47521
47543
  ${selector}
47522
47544
  ${note}
47545
+ ${diagnostics}
47523
47546
  </td>`;
47524
47547
  }
47548
+ function renderStepDiagnostics(d) {
47549
+ if (!d?.timedOut)
47550
+ return "";
47551
+ const hidden = d.probes?.filter((p) => p.present && !p.visible) ?? [];
47552
+ const missing = d.probes?.filter((p) => !p.present) ?? [];
47553
+ const rows = [
47554
+ `<div>esperou ${d.elapsedMs}ms / orçamento ${d.budgetMs}ms (${d.pollCount} poll(s))</div>`,
47555
+ ...hidden.map((p) => `<div>⚠️ presente porém oculto: <code>${escapeHtml(p.selector)}</code></div>`),
47556
+ ...missing.map((p) => `<div>não encontrado no DOM: <code>${escapeHtml(p.selector)}</code></div>`)
47557
+ ];
47558
+ return `<div class="step-diagnostics">${rows.join("")}</div>`;
47559
+ }
47525
47560
  function renderChecksTable(run) {
47526
47561
  return `
47527
47562
  <div class="card">
@@ -49316,6 +49351,17 @@ import { writeFileSync as writeFileSync10 } from "node:fs";
49316
49351
  import chalk8 from "chalk";
49317
49352
  import ora4 from "ora";
49318
49353
 
49354
+ // src/util/localhost.ts
49355
+ var LOCALHOST_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"]);
49356
+ function isLocalhost(rawUrl) {
49357
+ try {
49358
+ const url = new URL(rawUrl);
49359
+ return LOCALHOST_HOSTS.has(url.hostname);
49360
+ } catch {
49361
+ return false;
49362
+ }
49363
+ }
49364
+
49319
49365
  // src/llm/pick-plp.ts
49320
49366
  init_client();
49321
49367
  var PATH_BLOCKLIST = [
@@ -49494,6 +49540,8 @@ REGRAS CRÍTICAS:
49494
49540
 
49495
49541
  9. **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. Em especial: se a página parece ser uma LANDING PAGE (sem buy form, sem schema:Product, sem preço) e o step pede um buy/variant/cep selector, retorne string vazia — não force uma sugestão.
49496
49542
 
49543
+ 10. **Se receber "Diagnóstico" com "present-but-hidden: &lt;seletor&gt;"**, isso significa que ESSE seletor específico já casa o elemento certo no DOM, mas ele ficou invisível pelo tempo orçado — é um problema de timing/animação/dados assíncronos, NÃO um seletor errado. NÃO sugira esse mesmo seletor de novo. Considere: (a) um seletor de TRIGGER diferente que realmente dispara a revelação (ex: o clique caiu num elemento errado), ou (b) se nada mais plausível existe, retorne selector="" — não invente um seletor alternativo só para dar uma resposta.
49544
+
49497
49545
  Responda SEMPRE via tool_use suggest_recovery.
49498
49546
  `.trim();
49499
49547
  async function suggestRecovery(input) {
@@ -49504,6 +49552,7 @@ async function suggestRecovery(input) {
49504
49552
  userText: `Step: ${input.stepName}
49505
49553
  Ação desejada: ${input.intendedAction}
49506
49554
  ${input.alreadyTried?.length ? `Já tentei (não repita estes): ${input.alreadyTried.join(", ")}
49555
+ ` : ""}${input.diagnostics ? `Diagnóstico: ${input.diagnostics}
49507
49556
  ` : ""}
49508
49557
  HTML compactado da página NESTE momento:
49509
49558
  \`\`\`html
@@ -50383,14 +50432,14 @@ async function findElement(page, ctx, opts) {
50383
50432
  }
50384
50433
  return null;
50385
50434
  }
50386
- async function attemptRecovery(page, _ctx, stepName, intendedAction, alreadyTried) {
50435
+ async function attemptRecovery(page, _ctx, stepName, intendedAction, alreadyTried, diagnostics) {
50387
50436
  let html3 = "";
50388
50437
  try {
50389
50438
  html3 = await page.content();
50390
50439
  } catch {
50391
50440
  return null;
50392
50441
  }
50393
- const suggestion = await suggestRecovery({ stepName, intendedAction, html: html3, alreadyTried });
50442
+ const suggestion = await suggestRecovery({ stepName, intendedAction, html: html3, alreadyTried, diagnostics });
50394
50443
  if (!suggestion)
50395
50444
  return null;
50396
50445
  try {
@@ -50866,6 +50915,66 @@ async function isCartRevealed(page, expectedProductTitle, ctx) {
50866
50915
  }
50867
50916
  return isCartUiVisible(page, ctx);
50868
50917
  }
50918
+ var CART_REVEAL_BUDGET_MS = 4000;
50919
+ var CART_REVEAL_BUDGET_LOCALHOST_MS = 8000;
50920
+ var CART_REVEAL_POLL_INTERVAL_MS = 200;
50921
+ var CART_REVEAL_HOVER_PROBE_MS = 1500;
50922
+ async function probeCartSelectors(page, ctx) {
50923
+ const selectors = [...new Set([...selFor(ctx, "minicartPanel"), ...selFor(ctx, "cartOpenedIndicator")])];
50924
+ const probes = [];
50925
+ for (const sel of selectors) {
50926
+ try {
50927
+ const loc = page.locator(sel);
50928
+ const present = await withCap(loc.count(), 300, 0) > 0;
50929
+ const visible = present ? await withCap(loc.first().isVisible().catch(() => false), 300, false) : false;
50930
+ probes.push({ selector: sel, present, visible });
50931
+ } catch {
50932
+ probes.push({ selector: sel, present: false, visible: false });
50933
+ }
50934
+ }
50935
+ return probes;
50936
+ }
50937
+ function summarizeCartRevealDiagnostics(d) {
50938
+ const parts = [`waited ${d.elapsedMs}ms/${d.budgetMs}ms (${d.pollCount} poll(s))`];
50939
+ const hidden = d.probes?.filter((p) => p.present && !p.visible) ?? [];
50940
+ const missing = d.probes?.filter((p) => !p.present) ?? [];
50941
+ if (hidden.length) {
50942
+ parts.push(`present-but-hidden: ${hidden.map((p) => p.selector).join(", ")}`);
50943
+ }
50944
+ if (missing.length) {
50945
+ parts.push(`not-in-dom: ${missing.map((p) => p.selector).join(", ")}`);
50946
+ }
50947
+ return parts.join(" — ");
50948
+ }
50949
+ async function waitForCartReveal(page, expectedProductTitle, ctx, timeoutMs) {
50950
+ const start = Date.now();
50951
+ const deadline = start + timeoutMs;
50952
+ let pollCount = 0;
50953
+ for (;; ) {
50954
+ pollCount++;
50955
+ const marker = await isCartRevealed(page, expectedProductTitle, ctx);
50956
+ if (marker) {
50957
+ dlog2(ctx, ` waitForCartReveal: revealed after ${pollCount} poll(s) (${marker})`);
50958
+ return {
50959
+ marker,
50960
+ diagnostics: { timedOut: false, budgetMs: timeoutMs, elapsedMs: Date.now() - start, pollCount }
50961
+ };
50962
+ }
50963
+ if (Date.now() >= deadline) {
50964
+ const probes = await probeCartSelectors(page, ctx);
50965
+ const diagnostics = {
50966
+ timedOut: true,
50967
+ budgetMs: timeoutMs,
50968
+ elapsedMs: Date.now() - start,
50969
+ pollCount,
50970
+ probes
50971
+ };
50972
+ dlog2(ctx, ` waitForCartReveal: ${summarizeCartRevealDiagnostics(diagnostics)}`);
50973
+ return { marker: null, diagnostics };
50974
+ }
50975
+ await page.waitForTimeout(CART_REVEAL_POLL_INTERVAL_MS);
50976
+ }
50977
+ }
50869
50978
  async function validateCartContainsTitleQuick(page, expectedTitle, ctx) {
50870
50979
  const quickSelectors = [
50871
50980
  ...ctx ? selFor(ctx, "minicartPanel") : [],
@@ -50903,7 +51012,9 @@ async function waitForCartHydration(page) {
50903
51012
  }
50904
51013
  async function openMinicart(page, trigger, ctx, expectedProductTitle) {
50905
51014
  const beforeUrl = page.url();
50906
- dlog2(ctx, ` openMinicart: starting — trigger=${trigger.selector} title=${expectedProductTitle?.slice(0, 40) ?? "none"}`);
51015
+ let lastDiagnostics;
51016
+ const revealBudget = ctx.rc.cartRevealTimeoutMs ?? (isLocalhost(beforeUrl) ? CART_REVEAL_BUDGET_LOCALHOST_MS : CART_REVEAL_BUDGET_MS);
51017
+ dlog2(ctx, ` openMinicart: starting — trigger=${trigger.selector} title=${expectedProductTitle?.slice(0, 40) ?? "none"} revealBudget=${revealBudget}ms`);
50907
51018
  await Promise.race([
50908
51019
  dismissOverlays(page, ctx),
50909
51020
  new Promise((resolve) => setTimeout(resolve, 4000))
@@ -50922,11 +51033,12 @@ async function openMinicart(page, trigger, ctx, expectedProductTitle) {
50922
51033
  await trigger.locator.hover({ timeout: 3000 }).catch(() => {
50923
51034
  return;
50924
51035
  });
50925
- await page.waitForTimeout(1500);
50926
- const hoverOpened = await isCartRevealed(page, expectedProductTitle, ctx);
50927
- if (hoverOpened) {
50928
- dlog2(ctx, ` openMinicart: hover opened drawer (${hoverOpened})`);
50929
- return { method: "hover", url: page.url(), visibleMarker: hoverOpened };
51036
+ const hoverProbeBudget = Math.min(CART_REVEAL_HOVER_PROBE_MS, revealBudget);
51037
+ const hover1 = await waitForCartReveal(page, expectedProductTitle, ctx, hoverProbeBudget);
51038
+ lastDiagnostics = hover1.diagnostics;
51039
+ if (hover1.marker) {
51040
+ dlog2(ctx, ` openMinicart: hover opened drawer (${hover1.marker})`);
51041
+ return { method: "hover", url: page.url(), visibleMarker: hover1.marker, diagnostics: hover1.diagnostics };
50930
51042
  }
50931
51043
  }
50932
51044
  if (ctx.viewport === "mobile") {
@@ -50947,11 +51059,11 @@ async function openMinicart(page, trigger, ctx, expectedProductTitle) {
50947
51059
  dlog2(ctx, ` openMinicart: tap navigated → ${page.url()} (settled)`);
50948
51060
  return { method: "click-navigate", url: page.url(), visibleMarker: null };
50949
51061
  }
50950
- await page.waitForTimeout(800);
50951
- const tapOpened = await isCartRevealed(page, expectedProductTitle, ctx);
50952
- if (tapOpened) {
50953
- dlog2(ctx, ` openMinicart: tap opened drawer (${tapOpened})`);
50954
- return { method: "click", url: page.url(), visibleMarker: tapOpened };
51062
+ const tap = await waitForCartReveal(page, expectedProductTitle, ctx, revealBudget);
51063
+ lastDiagnostics = tap.diagnostics;
51064
+ if (tap.marker) {
51065
+ dlog2(ctx, ` openMinicart: tap opened drawer (${tap.marker})`);
51066
+ return { method: "click", url: page.url(), visibleMarker: tap.marker, diagnostics: tap.diagnostics };
50955
51067
  }
50956
51068
  }
50957
51069
  dlog2(ctx, ` openMinicart: trying force-click on ${trigger.selector}${triggerHref ? ` (href=${triggerHref})` : ""}`);
@@ -50972,22 +51084,35 @@ async function openMinicart(page, trigger, ctx, expectedProductTitle) {
50972
51084
  dlog2(ctx, ` openMinicart: click navigated → ${page.url()} (settled)`);
50973
51085
  return { method: "click-navigate", url: page.url(), visibleMarker: null };
50974
51086
  }
50975
- await page.waitForTimeout(1500);
50976
- const clickOpened = await isCartRevealed(page, expectedProductTitle, ctx);
50977
- if (clickOpened) {
50978
- dlog2(ctx, ` openMinicart: click opened drawer (${clickOpened})`);
50979
- return { method: "click", url: afterClickUrl, visibleMarker: clickOpened };
51087
+ const click = await waitForCartReveal(page, expectedProductTitle, ctx, revealBudget);
51088
+ lastDiagnostics = click.diagnostics;
51089
+ if (click.marker) {
51090
+ dlog2(ctx, ` openMinicart: click opened drawer (${click.marker})`);
51091
+ return { method: "click", url: afterClickUrl, visibleMarker: click.marker, diagnostics: click.diagnostics };
51092
+ }
51093
+ const blockingOverlay = await dismissBlockingOverlay(page, ctx, trigger.locator);
51094
+ if (blockingOverlay?.dismissed) {
51095
+ dlog2(ctx, ` openMinicart: cleared blocking overlay (${blockingOverlay.method}) — retrying click`);
51096
+ await trigger.locator.click({ force: true, timeout: 4000 }).catch(() => {
51097
+ return;
51098
+ });
51099
+ const retry = await waitForCartReveal(page, expectedProductTitle, ctx, revealBudget);
51100
+ lastDiagnostics = retry.diagnostics;
51101
+ if (retry.marker) {
51102
+ dlog2(ctx, ` openMinicart: click opened drawer after overlay dismissal (${retry.marker})`);
51103
+ return { method: "click", url: page.url(), visibleMarker: retry.marker, diagnostics: retry.diagnostics };
51104
+ }
50980
51105
  }
50981
51106
  if (ctx.viewport !== "desktop") {
50982
51107
  dlog2(ctx, ` openMinicart: click didn't reveal cart, trying hover (mobile)`);
50983
51108
  await trigger.locator.hover({ timeout: 3000 }).catch(() => {
50984
51109
  return;
50985
51110
  });
50986
- await page.waitForTimeout(1500);
50987
- const hoverOpened = await isCartRevealed(page, expectedProductTitle, ctx);
50988
- if (hoverOpened) {
50989
- dlog2(ctx, ` openMinicart: hover opened drawer (${hoverOpened})`);
50990
- return { method: "hover", url: page.url(), visibleMarker: hoverOpened };
51111
+ const hover2 = await waitForCartReveal(page, expectedProductTitle, ctx, revealBudget);
51112
+ lastDiagnostics = hover2.diagnostics;
51113
+ if (hover2.marker) {
51114
+ dlog2(ctx, ` openMinicart: hover opened drawer (${hover2.marker})`);
51115
+ return { method: "hover", url: page.url(), visibleMarker: hover2.marker, diagnostics: hover2.diagnostics };
50991
51116
  }
50992
51117
  }
50993
51118
  if (hrefHasCartTarget && triggerHref) {
@@ -51010,8 +51135,8 @@ async function openMinicart(page, trigger, ctx, expectedProductTitle) {
51010
51135
  }
51011
51136
  }
51012
51137
  }
51013
- dlog2(ctx, " openMinicart: failed — no cart revealed by hover/click/goto");
51014
- return { method: "failed", url: page.url(), visibleMarker: null };
51138
+ dlog2(ctx, ` openMinicart: failed — no cart revealed by hover/click/goto${lastDiagnostics ? ` (${summarizeCartRevealDiagnostics(lastDiagnostics)})` : ""}`);
51139
+ return { method: "failed", url: page.url(), visibleMarker: null, diagnostics: lastDiagnostics };
51015
51140
  }
51016
51141
  function normalizeTitle(s) {
51017
51142
  return s.toLowerCase().replace(/[®©™]/g, "").replace(/[^\p{L}\p{N}\s]+/gu, " ").replace(/\s+/g, " ").trim();
@@ -51868,6 +51993,7 @@ async function flowPurchaseJourney(ctx) {
51868
51993
  usedSelector: buyHit.selector,
51869
51994
  recoveredByLlm: buyRecovered || undefined,
51870
51995
  note: validation.status === "ok" ? undefined : validation.note,
51996
+ diagnostics: validation.diagnostics,
51871
51997
  detail: {
51872
51998
  signal: validation.signal,
51873
51999
  errorText: validation.errorText,
@@ -51897,13 +52023,26 @@ async function flowPurchaseJourney(ctx) {
51897
52023
  let cartOpenMethod = "failed";
51898
52024
  let miniText = "";
51899
52025
  let cartRevealMode = "unknown";
52026
+ let revealDiagnostics;
51900
52027
  const drawerAlreadyOpen = await isCartRevealed(page, expectedProductTitle, ctx) !== null;
51901
52028
  if (miniHit) {
51902
52029
  miniText = await miniHit.locator.innerText().catch(() => "");
51903
52030
  cartRevealMode = await detectCartRevealMode(page, miniHit.locator, drawerAlreadyOpen, ctx.viewport).catch(() => "unknown");
51904
52031
  dlog2(ctx, `step 7 open-minicart: cartRevealMode=${cartRevealMode}`);
51905
- const openResult = await openMinicart(page, miniHit, ctx, expectedProductTitle);
52032
+ let openResult = await openMinicart(page, miniHit, ctx, expectedProductTitle);
51906
52033
  cartOpenMethod = openResult.method;
52034
+ revealDiagnostics = openResult.diagnostics;
52035
+ if (cartOpenMethod === "failed" && revealDiagnostics && budget.remaining > 0) {
52036
+ const recovery = await attemptRecovery(page, ctx, "open-minicart", "Abrir o minicart/drawer do carrinho — o trigger foi clicado mas o drawer não revelou", [miniHit.selector], summarizeCartRevealDiagnostics(revealDiagnostics));
52037
+ if (recovery) {
52038
+ budget.remaining--;
52039
+ miniHit = recovery;
52040
+ miniRecovered = true;
52041
+ openResult = await openMinicart(page, miniHit, ctx, expectedProductTitle);
52042
+ cartOpenMethod = openResult.method;
52043
+ revealDiagnostics = openResult.diagnostics;
52044
+ }
52045
+ }
51907
52046
  } else {
51908
52047
  if (drawerAlreadyOpen) {
51909
52048
  cartOpenMethod = "already-open";
@@ -51938,6 +52077,7 @@ async function flowPurchaseJourney(ctx) {
51938
52077
  const isProdCartEmptyQuirk = ctx.acceptProdQuirks === true && ctx.side === "prod" && step7Validation !== undefined && !step7Validation.found && cartEmptyReason.startsWith("cart genuinely empty");
51939
52078
  const step7Status = isProdCartEmptyQuirk ? "skipped" : cartOpenMethod === "failed" ? "failed" : step7Validation && !step7Validation.found ? "failed" : "ok";
51940
52079
  const step7QuirkNote = isProdCartEmptyQuirk ? "cart-empty-prod-quirk: aceito via --accept-prod-quirks (cartRevealMode validated by separate check)" : undefined;
52080
+ const step7RevealFailureNote = cartOpenMethod === "failed" && revealDiagnostics ? `minicart não revelou — ${summarizeCartRevealDiagnostics(revealDiagnostics)}` : undefined;
51941
52081
  steps.push({
51942
52082
  step: 7,
51943
52083
  name: "open-minicart",
@@ -51952,13 +52092,14 @@ async function flowPurchaseJourney(ctx) {
51952
52092
  cartOpenMethod,
51953
52093
  cartRevealMode,
51954
52094
  cartValidation: step7Validation,
51955
- note: step7QuirkNote,
52095
+ diagnostics: revealDiagnostics,
52096
+ note: step7QuirkNote ?? step7RevealFailureNote,
51956
52097
  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",
51957
52098
  selectorKey: miniHit ? "minicartTrigger" : undefined,
51958
52099
  usedSelector: miniHit?.selector,
51959
52100
  recoveredByLlm: miniRecovered || undefined
51960
52101
  });
51961
- reportEnd(7, "open-minicart", step7Status, Date.now() - t7, step7QuirkNote);
52102
+ reportEnd(7, "open-minicart", step7Status, Date.now() - t7, step7QuirkNote ?? step7RevealFailureNote);
51962
52103
  if (isProdCartEmptyQuirk) {
51963
52104
  const quirkNote = "cart-empty-prod-quirk: skipped (depende do cart que prod não persistiu)";
51964
52105
  reportStart(8, "shipping-calc-cart");
@@ -52299,9 +52440,22 @@ async function findPlusButtonNear(input) {
52299
52440
  return null;
52300
52441
  }
52301
52442
  async function validateAddToCart(page, ctx, cartCountBefore, beforeUrl) {
52302
- const deadline = Date.now() + resolveAddToCartConfirmMs(ctx.rc);
52443
+ const startedAt = Date.now();
52444
+ const budgetMs = resolveAddToCartConfirmMs(ctx.rc);
52445
+ const deadline = startedAt + budgetMs;
52303
52446
  let lastErrorText;
52447
+ let pollCount = 0;
52448
+ const drawerSelectors = [
52449
+ ...selFor(ctx, "cartOpenedIndicator"),
52450
+ "[role='dialog']",
52451
+ "[data-minicart][aria-expanded='true']",
52452
+ "[data-cart-drawer].open",
52453
+ "[data-minicart-drawer]:not([hidden])",
52454
+ ".minicart--open",
52455
+ ".cart-drawer--open"
52456
+ ];
52304
52457
  while (Date.now() < deadline) {
52458
+ pollCount++;
52305
52459
  const currentUrl = page.url();
52306
52460
  if (currentUrl !== beforeUrl && /\/(cart|carrinho|checkout)(\/|$|\?)/i.test(currentUrl)) {
52307
52461
  return {
@@ -52318,15 +52472,6 @@ async function validateAddToCart(page, ctx, cartCountBefore, beforeUrl) {
52318
52472
  note: `Contador do minicart foi de ${cartCountBefore} para ${cartCountNow}`
52319
52473
  };
52320
52474
  }
52321
- const drawerSelectors = [
52322
- ...selFor(ctx, "cartOpenedIndicator"),
52323
- "[role='dialog']",
52324
- "[data-minicart][aria-expanded='true']",
52325
- "[data-cart-drawer].open",
52326
- "[data-minicart-drawer]:not([hidden])",
52327
- ".minicart--open",
52328
- ".cart-drawer--open"
52329
- ];
52330
52475
  for (const sel of drawerSelectors) {
52331
52476
  const el = page.locator(sel).first();
52332
52477
  if (await el.isVisible({ timeout: 200 }).catch(() => false)) {
@@ -52359,18 +52504,32 @@ async function validateAddToCart(page, ctx, cartCountBefore, beforeUrl) {
52359
52504
  } catch {}
52360
52505
  await page.waitForTimeout(250);
52361
52506
  }
52507
+ const diagnostics = {
52508
+ timedOut: true,
52509
+ budgetMs,
52510
+ elapsedMs: Date.now() - startedAt,
52511
+ pollCount,
52512
+ probes: await Promise.all(drawerSelectors.map(async (selector) => {
52513
+ const loc = page.locator(selector).first();
52514
+ const present = await loc.count().catch(() => 0) > 0;
52515
+ const visible = present ? await loc.isVisible({ timeout: 200 }).catch(() => false) : false;
52516
+ return { selector, present, visible };
52517
+ }))
52518
+ };
52362
52519
  if (lastErrorText) {
52363
52520
  return {
52364
52521
  status: "failed",
52365
52522
  signal: "error-text",
52366
52523
  note: `add-to-cart silenciosamente falhou: '${lastErrorText}' visível na página`,
52367
- errorText: lastErrorText
52524
+ errorText: lastErrorText,
52525
+ diagnostics
52368
52526
  };
52369
52527
  }
52370
52528
  return {
52371
52529
  status: "failed",
52372
52530
  signal: "no-signal",
52373
- note: "add-to-cart sem confirmação visível (minicart não atualizou, sem drawer, sem mudança de URL)"
52531
+ note: "add-to-cart sem confirmação visível (minicart não atualizou, sem drawer, sem mudança de URL)",
52532
+ diagnostics
52374
52533
  };
52375
52534
  }
52376
52535
 
@@ -58406,17 +58565,6 @@ function formatSecs(ms) {
58406
58565
  return `${min}m${sec.toString().padStart(2, "0")}s`;
58407
58566
  }
58408
58567
 
58409
- // src/util/localhost.ts
58410
- var LOCALHOST_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"]);
58411
- function isLocalhost(rawUrl) {
58412
- try {
58413
- const url = new URL(rawUrl);
58414
- return LOCALHOST_HOSTS.has(url.hostname);
58415
- } catch {
58416
- return false;
58417
- }
58418
- }
58419
-
58420
58568
  // src/util/timing.ts
58421
58569
  async function withTiming(fn) {
58422
58570
  const start = performance.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/parity",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "E2E parity validator for site migrations. Compares prod vs cand and reports UI, functional, SEO, visual, and Web Vitals deltas with an LLM-ranked HTML report.",
5
5
  "type": "module",
6
6
  "license": "MIT",