@decocms/parity 0.15.1 → 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 +18 -0
  2. package/dist/cli.js +214 -55
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,10 +7,28 @@ 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
+
22
+ ## [0.16.0](https://github.com/decocms/parity/compare/v0.15.1...v0.16.0) (2026-07-28)
23
+
10
24
  ### Added
11
25
 
12
26
  * **Configurable `minicartPanel` selector for open-minicart reveal detection (issue #149).** `isCartUiVisible` and the title-scope sweep in `validateCartContainsTitleQuick` used a hardcoded list of name-based patterns (`[role='dialog']`, `[class*='minicart']`, etc.) to detect that the cart drawer was open. Any site built with utility-CSS (Tailwind) and `data-qa-*` testing attributes — a common modern stack — has no class/attribute those patterns can match, so detection always returns `null` and the step reports "failed" even when the drawer is genuinely open. The new `minicartPanel` selector key (`.parityrc.json`) is tried first, ahead of all hardcoded patterns. Built-in defaults cover `[data-qa-minicart]`, `[data-minicart]`, `[data-minicart-drawer]`, `[data-testid='minicart']` and common class patterns — so the `data-qa-*` case is handled out-of-the-box. Override with a single selector that matches the drawer root for your specific site.
13
27
 
28
+ ### Fixed
29
+
30
+ * **`dismissOverlays` stall in `openMinicart` (issue #151).** The named-selector sweep inside `dismissOverlays` used a 400ms per-selector cap; with 12+ selectors none of which match, that's ~5s minimum before `openMinicart` could proceed — and on heavy pages the CDP calls could stall much longer, causing 50-100s gaps with zero debug output. Fixed by: (1) tightening the per-selector probe to 80ms (fast-failing `count()=0` before any `isVisible` call), (2) adding `dlog` at the entry of `dismissOverlays` and `openMinicart` so slow sweeps show up in `DEBUG_PARITY=1` output, and (3) wrapping the `dismissOverlays` call inside `openMinicart` with a 4s hard cap so it degrades gracefully instead of silently consuming the step budget.
31
+
14
32
  ## [0.15.0](https://github.com/decocms/parity/compare/v0.14.0...v0.15.0) (2026-07-27)
15
33
 
16
34
  ### 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 {
@@ -50719,15 +50768,21 @@ async function dismissBlockingOverlay(page, ctx, target) {
50719
50768
  function describe(b) {
50720
50769
  return `${b.tag}${b.id ? `#${b.id}` : ""}${b.className ? `.${b.className.split(/\s+/)[0]}` : ""}`;
50721
50770
  }
50771
+ var OVERLAY_PROBE_CAP_MS = 80;
50722
50772
  async function dismissOverlays(page, ctx) {
50773
+ const sels = overlaySelectorsFor(ctx.rc);
50774
+ dlog2(ctx, ` dismissOverlays: checking ${sels.length} selector(s)…`);
50723
50775
  const dismissals = [];
50724
- for (const sel of overlaySelectorsFor(ctx.rc)) {
50776
+ for (const sel of sels) {
50725
50777
  try {
50726
50778
  const overlay = page.locator(sel).first();
50727
- if (!await withCap(overlay.isVisible({ timeout: 200 }).catch(() => false), 400, false))
50779
+ const cnt = await withCap(overlay.count(), OVERLAY_PROBE_CAP_MS, 0);
50780
+ if (cnt === 0)
50781
+ continue;
50782
+ if (!await withCap(overlay.isVisible({ timeout: OVERLAY_PROBE_CAP_MS }).catch(() => false), OVERLAY_PROBE_CAP_MS, false))
50728
50783
  continue;
50729
50784
  const closer = overlay.locator(CLOSE_BUTTON_SELECTOR).first();
50730
- if (await withCap(closer.isVisible({ timeout: 200 }).catch(() => false), 400, false)) {
50785
+ if (await withCap(closer.isVisible({ timeout: OVERLAY_PROBE_CAP_MS }).catch(() => false), OVERLAY_PROBE_CAP_MS, false)) {
50731
50786
  await closer.click({ timeout: 1500 }).catch(() => {
50732
50787
  return;
50733
50788
  });
@@ -50860,6 +50915,66 @@ async function isCartRevealed(page, expectedProductTitle, ctx) {
50860
50915
  }
50861
50916
  return isCartUiVisible(page, ctx);
50862
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
+ }
50863
50978
  async function validateCartContainsTitleQuick(page, expectedTitle, ctx) {
50864
50979
  const quickSelectors = [
50865
50980
  ...ctx ? selFor(ctx, "minicartPanel") : [],
@@ -50897,7 +51012,14 @@ async function waitForCartHydration(page) {
50897
51012
  }
50898
51013
  async function openMinicart(page, trigger, ctx, expectedProductTitle) {
50899
51014
  const beforeUrl = page.url();
50900
- await dismissOverlays(page, ctx);
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`);
51018
+ await Promise.race([
51019
+ dismissOverlays(page, ctx),
51020
+ new Promise((resolve) => setTimeout(resolve, 4000))
51021
+ ]);
51022
+ dlog2(ctx, " openMinicart: dismissOverlays done — checking alreadyOpen…");
50901
51023
  await page.waitForTimeout(800);
50902
51024
  const alreadyOpen = await isCartRevealed(page, expectedProductTitle, ctx);
50903
51025
  if (alreadyOpen) {
@@ -50911,11 +51033,12 @@ async function openMinicart(page, trigger, ctx, expectedProductTitle) {
50911
51033
  await trigger.locator.hover({ timeout: 3000 }).catch(() => {
50912
51034
  return;
50913
51035
  });
50914
- await page.waitForTimeout(1500);
50915
- const hoverOpened = await isCartRevealed(page, expectedProductTitle, ctx);
50916
- if (hoverOpened) {
50917
- dlog2(ctx, ` openMinicart: hover opened drawer (${hoverOpened})`);
50918
- 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 };
50919
51042
  }
50920
51043
  }
50921
51044
  if (ctx.viewport === "mobile") {
@@ -50936,11 +51059,11 @@ async function openMinicart(page, trigger, ctx, expectedProductTitle) {
50936
51059
  dlog2(ctx, ` openMinicart: tap navigated → ${page.url()} (settled)`);
50937
51060
  return { method: "click-navigate", url: page.url(), visibleMarker: null };
50938
51061
  }
50939
- await page.waitForTimeout(800);
50940
- const tapOpened = await isCartRevealed(page, expectedProductTitle, ctx);
50941
- if (tapOpened) {
50942
- dlog2(ctx, ` openMinicart: tap opened drawer (${tapOpened})`);
50943
- 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 };
50944
51067
  }
50945
51068
  }
50946
51069
  dlog2(ctx, ` openMinicart: trying force-click on ${trigger.selector}${triggerHref ? ` (href=${triggerHref})` : ""}`);
@@ -50961,22 +51084,35 @@ async function openMinicart(page, trigger, ctx, expectedProductTitle) {
50961
51084
  dlog2(ctx, ` openMinicart: click navigated → ${page.url()} (settled)`);
50962
51085
  return { method: "click-navigate", url: page.url(), visibleMarker: null };
50963
51086
  }
50964
- await page.waitForTimeout(1500);
50965
- const clickOpened = await isCartRevealed(page, expectedProductTitle, ctx);
50966
- if (clickOpened) {
50967
- dlog2(ctx, ` openMinicart: click opened drawer (${clickOpened})`);
50968
- 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
+ }
50969
51105
  }
50970
51106
  if (ctx.viewport !== "desktop") {
50971
51107
  dlog2(ctx, ` openMinicart: click didn't reveal cart, trying hover (mobile)`);
50972
51108
  await trigger.locator.hover({ timeout: 3000 }).catch(() => {
50973
51109
  return;
50974
51110
  });
50975
- await page.waitForTimeout(1500);
50976
- const hoverOpened = await isCartRevealed(page, expectedProductTitle, ctx);
50977
- if (hoverOpened) {
50978
- dlog2(ctx, ` openMinicart: hover opened drawer (${hoverOpened})`);
50979
- 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 };
50980
51116
  }
50981
51117
  }
50982
51118
  if (hrefHasCartTarget && triggerHref) {
@@ -50999,8 +51135,8 @@ async function openMinicart(page, trigger, ctx, expectedProductTitle) {
50999
51135
  }
51000
51136
  }
51001
51137
  }
51002
- dlog2(ctx, " openMinicart: failed — no cart revealed by hover/click/goto");
51003
- 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 };
51004
51140
  }
51005
51141
  function normalizeTitle(s) {
51006
51142
  return s.toLowerCase().replace(/[®©™]/g, "").replace(/[^\p{L}\p{N}\s]+/gu, " ").replace(/\s+/g, " ").trim();
@@ -51857,6 +51993,7 @@ async function flowPurchaseJourney(ctx) {
51857
51993
  usedSelector: buyHit.selector,
51858
51994
  recoveredByLlm: buyRecovered || undefined,
51859
51995
  note: validation.status === "ok" ? undefined : validation.note,
51996
+ diagnostics: validation.diagnostics,
51860
51997
  detail: {
51861
51998
  signal: validation.signal,
51862
51999
  errorText: validation.errorText,
@@ -51886,13 +52023,26 @@ async function flowPurchaseJourney(ctx) {
51886
52023
  let cartOpenMethod = "failed";
51887
52024
  let miniText = "";
51888
52025
  let cartRevealMode = "unknown";
52026
+ let revealDiagnostics;
51889
52027
  const drawerAlreadyOpen = await isCartRevealed(page, expectedProductTitle, ctx) !== null;
51890
52028
  if (miniHit) {
51891
52029
  miniText = await miniHit.locator.innerText().catch(() => "");
51892
52030
  cartRevealMode = await detectCartRevealMode(page, miniHit.locator, drawerAlreadyOpen, ctx.viewport).catch(() => "unknown");
51893
52031
  dlog2(ctx, `step 7 open-minicart: cartRevealMode=${cartRevealMode}`);
51894
- const openResult = await openMinicart(page, miniHit, ctx, expectedProductTitle);
52032
+ let openResult = await openMinicart(page, miniHit, ctx, expectedProductTitle);
51895
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
+ }
51896
52046
  } else {
51897
52047
  if (drawerAlreadyOpen) {
51898
52048
  cartOpenMethod = "already-open";
@@ -51927,6 +52077,7 @@ async function flowPurchaseJourney(ctx) {
51927
52077
  const isProdCartEmptyQuirk = ctx.acceptProdQuirks === true && ctx.side === "prod" && step7Validation !== undefined && !step7Validation.found && cartEmptyReason.startsWith("cart genuinely empty");
51928
52078
  const step7Status = isProdCartEmptyQuirk ? "skipped" : cartOpenMethod === "failed" ? "failed" : step7Validation && !step7Validation.found ? "failed" : "ok";
51929
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;
51930
52081
  steps.push({
51931
52082
  step: 7,
51932
52083
  name: "open-minicart",
@@ -51941,13 +52092,14 @@ async function flowPurchaseJourney(ctx) {
51941
52092
  cartOpenMethod,
51942
52093
  cartRevealMode,
51943
52094
  cartValidation: step7Validation,
51944
- note: step7QuirkNote,
52095
+ diagnostics: revealDiagnostics,
52096
+ note: step7QuirkNote ?? step7RevealFailureNote,
51945
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",
51946
52098
  selectorKey: miniHit ? "minicartTrigger" : undefined,
51947
52099
  usedSelector: miniHit?.selector,
51948
52100
  recoveredByLlm: miniRecovered || undefined
51949
52101
  });
51950
- reportEnd(7, "open-minicart", step7Status, Date.now() - t7, step7QuirkNote);
52102
+ reportEnd(7, "open-minicart", step7Status, Date.now() - t7, step7QuirkNote ?? step7RevealFailureNote);
51951
52103
  if (isProdCartEmptyQuirk) {
51952
52104
  const quirkNote = "cart-empty-prod-quirk: skipped (depende do cart que prod não persistiu)";
51953
52105
  reportStart(8, "shipping-calc-cart");
@@ -52288,9 +52440,22 @@ async function findPlusButtonNear(input) {
52288
52440
  return null;
52289
52441
  }
52290
52442
  async function validateAddToCart(page, ctx, cartCountBefore, beforeUrl) {
52291
- const deadline = Date.now() + resolveAddToCartConfirmMs(ctx.rc);
52443
+ const startedAt = Date.now();
52444
+ const budgetMs = resolveAddToCartConfirmMs(ctx.rc);
52445
+ const deadline = startedAt + budgetMs;
52292
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
+ ];
52293
52457
  while (Date.now() < deadline) {
52458
+ pollCount++;
52294
52459
  const currentUrl = page.url();
52295
52460
  if (currentUrl !== beforeUrl && /\/(cart|carrinho|checkout)(\/|$|\?)/i.test(currentUrl)) {
52296
52461
  return {
@@ -52307,15 +52472,6 @@ async function validateAddToCart(page, ctx, cartCountBefore, beforeUrl) {
52307
52472
  note: `Contador do minicart foi de ${cartCountBefore} para ${cartCountNow}`
52308
52473
  };
52309
52474
  }
52310
- const drawerSelectors = [
52311
- ...selFor(ctx, "cartOpenedIndicator"),
52312
- "[role='dialog']",
52313
- "[data-minicart][aria-expanded='true']",
52314
- "[data-cart-drawer].open",
52315
- "[data-minicart-drawer]:not([hidden])",
52316
- ".minicart--open",
52317
- ".cart-drawer--open"
52318
- ];
52319
52475
  for (const sel of drawerSelectors) {
52320
52476
  const el = page.locator(sel).first();
52321
52477
  if (await el.isVisible({ timeout: 200 }).catch(() => false)) {
@@ -52348,18 +52504,32 @@ async function validateAddToCart(page, ctx, cartCountBefore, beforeUrl) {
52348
52504
  } catch {}
52349
52505
  await page.waitForTimeout(250);
52350
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
+ };
52351
52519
  if (lastErrorText) {
52352
52520
  return {
52353
52521
  status: "failed",
52354
52522
  signal: "error-text",
52355
52523
  note: `add-to-cart silenciosamente falhou: '${lastErrorText}' visível na página`,
52356
- errorText: lastErrorText
52524
+ errorText: lastErrorText,
52525
+ diagnostics
52357
52526
  };
52358
52527
  }
52359
52528
  return {
52360
52529
  status: "failed",
52361
52530
  signal: "no-signal",
52362
- 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
52363
52533
  };
52364
52534
  }
52365
52535
 
@@ -58395,17 +58565,6 @@ function formatSecs(ms) {
58395
58565
  return `${min}m${sec.toString().padStart(2, "0")}s`;
58396
58566
  }
58397
58567
 
58398
- // src/util/localhost.ts
58399
- var LOCALHOST_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"]);
58400
- function isLocalhost(rawUrl) {
58401
- try {
58402
- const url = new URL(rawUrl);
58403
- return LOCALHOST_HOSTS.has(url.hostname);
58404
- } catch {
58405
- return false;
58406
- }
58407
- }
58408
-
58409
58568
  // src/util/timing.ts
58410
58569
  async function withTiming(fn) {
58411
58570
  const start = performance.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/parity",
3
- "version": "0.15.1",
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",