@decocms/parity 0.16.0 → 0.17.1

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 +244 -54
  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
@@ -39020,6 +39020,21 @@ async function capturePage(page, opts) {
39020
39020
  let xRobotsTag = null;
39021
39021
  let vitals = null;
39022
39022
  let html3 = "";
39023
+ let _resolveDisconnect;
39024
+ const disconnectPromise = new Promise((r) => {
39025
+ _resolveDisconnect = r;
39026
+ });
39027
+ const browser = page.context().browser();
39028
+ const handleBrowserDisconnect = () => {
39029
+ dlog(opts.side, opts.viewport, " capturePage: browser disconnected — aborting capture");
39030
+ _resolveDisconnect();
39031
+ };
39032
+ const handlePageCrash = () => {
39033
+ dlog(opts.side, opts.viewport, " capturePage: page crashed — aborting capture");
39034
+ _resolveDisconnect();
39035
+ };
39036
+ browser?.on("disconnected", handleBrowserDisconnect);
39037
+ page.on("crash", handlePageCrash);
39023
39038
  const inner = async () => {
39024
39039
  try {
39025
39040
  dlog(opts.side, opts.viewport, ` capturePage: goto(${opts.url}) start`);
@@ -39130,7 +39145,7 @@ async function capturePage(page, opts) {
39130
39145
  };
39131
39146
  const SAFETY_MARGIN_MS = 1e4;
39132
39147
  const outerDeadlineMs = overallBudgetMs + SAFETY_MARGIN_MS;
39133
- return Promise.race([
39148
+ const result = await Promise.race([
39134
39149
  inner(),
39135
39150
  new Promise((resolve) => setTimeout(() => {
39136
39151
  state.console.push({
@@ -39138,8 +39153,18 @@ async function capturePage(page, opts) {
39138
39153
  text: `[capture-timeout] capturePage exceeded ${outerDeadlineMs}ms outer deadline — returning partial capture`
39139
39154
  });
39140
39155
  resolve(buildPartial());
39141
- }, outerDeadlineMs))
39156
+ }, outerDeadlineMs)),
39157
+ disconnectPromise.then(() => {
39158
+ state.console.push({
39159
+ type: "error",
39160
+ text: "[browser-disconnected] browser/page crashed — returning partial capture"
39161
+ });
39162
+ return buildPartial();
39163
+ })
39142
39164
  ]);
39165
+ browser?.off("disconnected", handleBrowserDisconnect);
39166
+ page.off("crash", handlePageCrash);
39167
+ return result;
39143
39168
  }
39144
39169
 
39145
39170
  // src/report/html-template.ts
@@ -40340,6 +40365,14 @@ var REPORT_CSS = `
40340
40365
  color: var(--state-warn);
40341
40366
  font-style: italic;
40342
40367
  }
40368
+ .step-diagnostics {
40369
+ margin-top: 4px;
40370
+ font-size: 10px;
40371
+ color: var(--text-muted);
40372
+ border-left: 2px solid var(--state-warn);
40373
+ padding-left: 6px;
40374
+ }
40375
+ .step-diagnostics code { background: var(--surface-base); padding: 1px 4px; border-radius: 3px; }
40343
40376
 
40344
40377
  /* Network waterfall — per-page SVG bar chart. Issue #78. */
40345
40378
  .wf-page {
@@ -41154,6 +41187,18 @@ var StepCapture = z.object({
41154
41187
  loginValidation: z.object({
41155
41188
  stage: z.enum(["form-loaded", "submitted", "succeeded", "error-shown"]),
41156
41189
  errorMessage: z.string().optional()
41190
+ }).optional(),
41191
+ diagnostics: z.object({
41192
+ timedOut: z.boolean().optional(),
41193
+ budgetMs: z.number().optional(),
41194
+ elapsedMs: z.number().optional(),
41195
+ pollCount: z.number().optional(),
41196
+ probes: z.array(z.object({
41197
+ selector: z.string(),
41198
+ present: z.boolean(),
41199
+ visible: z.boolean()
41200
+ })).optional(),
41201
+ toastText: z.string().optional()
41157
41202
  }).optional()
41158
41203
  });
41159
41204
  var FlowCapture = z.object({
@@ -41436,7 +41481,8 @@ var ParityRc = z.object({
41436
41481
  serverFnFloodBudget: z.number().optional(),
41437
41482
  serverFnPattern: z.string().optional(),
41438
41483
  overlaySelectors: z.array(z.string()).optional(),
41439
- addToCartConfirmMs: z.number().optional()
41484
+ addToCartConfirmMs: z.number().optional(),
41485
+ cartRevealTimeoutMs: z.number().optional()
41440
41486
  });
41441
41487
  var ParityIgnore = z.object({
41442
41488
  ignoreSelectorsVisual: z.array(z.string()).default([]),
@@ -47516,12 +47562,26 @@ function renderStepCell(s, runDir) {
47516
47562
  const screenshot = s.screenshotPath ? `<a class="step-shot" href="${escapeHtml(relPath(runDir, s.screenshotPath))}" target="_blank" title="open screenshot">\uD83D\uDCF7</a>` : "";
47517
47563
  const selector = s.usedSelector ? `<div class="step-selector"><code>${escapeHtml(s.usedSelector)}</code></div>` : "";
47518
47564
  const note = s.note ? `<div class="step-note">${escapeHtml(s.note)}</div>` : "";
47565
+ const diagnostics = renderStepDiagnostics(s.diagnostics);
47519
47566
  return `<td class="step-cell ${statusClass}">
47520
47567
  <div class="step-cell-head"><span class="step-status">${s.status}</span>${screenshot}<span class="dim">${s.durationMs}ms</span></div>
47521
47568
  ${selector}
47522
47569
  ${note}
47570
+ ${diagnostics}
47523
47571
  </td>`;
47524
47572
  }
47573
+ function renderStepDiagnostics(d) {
47574
+ if (!d?.timedOut)
47575
+ return "";
47576
+ const hidden = d.probes?.filter((p) => p.present && !p.visible) ?? [];
47577
+ const missing = d.probes?.filter((p) => !p.present) ?? [];
47578
+ const rows = [
47579
+ `<div>esperou ${d.elapsedMs}ms / orçamento ${d.budgetMs}ms (${d.pollCount} poll(s))</div>`,
47580
+ ...hidden.map((p) => `<div>⚠️ presente porém oculto: <code>${escapeHtml(p.selector)}</code></div>`),
47581
+ ...missing.map((p) => `<div>não encontrado no DOM: <code>${escapeHtml(p.selector)}</code></div>`)
47582
+ ];
47583
+ return `<div class="step-diagnostics">${rows.join("")}</div>`;
47584
+ }
47525
47585
  function renderChecksTable(run) {
47526
47586
  return `
47527
47587
  <div class="card">
@@ -49313,9 +49373,21 @@ async function cssTraceCommand(opts) {
49313
49373
 
49314
49374
  // src/commands/e2e.ts
49315
49375
  import { writeFileSync as writeFileSync10 } from "node:fs";
49376
+ import { spawn } from "node:child_process";
49316
49377
  import chalk8 from "chalk";
49317
49378
  import ora4 from "ora";
49318
49379
 
49380
+ // src/util/localhost.ts
49381
+ var LOCALHOST_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"]);
49382
+ function isLocalhost(rawUrl) {
49383
+ try {
49384
+ const url = new URL(rawUrl);
49385
+ return LOCALHOST_HOSTS.has(url.hostname);
49386
+ } catch {
49387
+ return false;
49388
+ }
49389
+ }
49390
+
49319
49391
  // src/llm/pick-plp.ts
49320
49392
  init_client();
49321
49393
  var PATH_BLOCKLIST = [
@@ -49494,6 +49566,8 @@ REGRAS CRÍTICAS:
49494
49566
 
49495
49567
  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
49568
 
49569
+ 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.
49570
+
49497
49571
  Responda SEMPRE via tool_use suggest_recovery.
49498
49572
  `.trim();
49499
49573
  async function suggestRecovery(input) {
@@ -49504,6 +49578,7 @@ async function suggestRecovery(input) {
49504
49578
  userText: `Step: ${input.stepName}
49505
49579
  Ação desejada: ${input.intendedAction}
49506
49580
  ${input.alreadyTried?.length ? `Já tentei (não repita estes): ${input.alreadyTried.join(", ")}
49581
+ ` : ""}${input.diagnostics ? `Diagnóstico: ${input.diagnostics}
49507
49582
  ` : ""}
49508
49583
  HTML compactado da página NESTE momento:
49509
49584
  \`\`\`html
@@ -50383,14 +50458,14 @@ async function findElement(page, ctx, opts) {
50383
50458
  }
50384
50459
  return null;
50385
50460
  }
50386
- async function attemptRecovery(page, _ctx, stepName, intendedAction, alreadyTried) {
50461
+ async function attemptRecovery(page, _ctx, stepName, intendedAction, alreadyTried, diagnostics) {
50387
50462
  let html3 = "";
50388
50463
  try {
50389
50464
  html3 = await page.content();
50390
50465
  } catch {
50391
50466
  return null;
50392
50467
  }
50393
- const suggestion = await suggestRecovery({ stepName, intendedAction, html: html3, alreadyTried });
50468
+ const suggestion = await suggestRecovery({ stepName, intendedAction, html: html3, alreadyTried, diagnostics });
50394
50469
  if (!suggestion)
50395
50470
  return null;
50396
50471
  try {
@@ -50866,6 +50941,66 @@ async function isCartRevealed(page, expectedProductTitle, ctx) {
50866
50941
  }
50867
50942
  return isCartUiVisible(page, ctx);
50868
50943
  }
50944
+ var CART_REVEAL_BUDGET_MS = 4000;
50945
+ var CART_REVEAL_BUDGET_LOCALHOST_MS = 8000;
50946
+ var CART_REVEAL_POLL_INTERVAL_MS = 200;
50947
+ var CART_REVEAL_HOVER_PROBE_MS = 1500;
50948
+ async function probeCartSelectors(page, ctx) {
50949
+ const selectors = [...new Set([...selFor(ctx, "minicartPanel"), ...selFor(ctx, "cartOpenedIndicator")])];
50950
+ const probes = [];
50951
+ for (const sel of selectors) {
50952
+ try {
50953
+ const loc = page.locator(sel);
50954
+ const present = await withCap(loc.count(), 300, 0) > 0;
50955
+ const visible = present ? await withCap(loc.first().isVisible().catch(() => false), 300, false) : false;
50956
+ probes.push({ selector: sel, present, visible });
50957
+ } catch {
50958
+ probes.push({ selector: sel, present: false, visible: false });
50959
+ }
50960
+ }
50961
+ return probes;
50962
+ }
50963
+ function summarizeCartRevealDiagnostics(d) {
50964
+ const parts = [`waited ${d.elapsedMs}ms/${d.budgetMs}ms (${d.pollCount} poll(s))`];
50965
+ const hidden = d.probes?.filter((p) => p.present && !p.visible) ?? [];
50966
+ const missing = d.probes?.filter((p) => !p.present) ?? [];
50967
+ if (hidden.length) {
50968
+ parts.push(`present-but-hidden: ${hidden.map((p) => p.selector).join(", ")}`);
50969
+ }
50970
+ if (missing.length) {
50971
+ parts.push(`not-in-dom: ${missing.map((p) => p.selector).join(", ")}`);
50972
+ }
50973
+ return parts.join(" — ");
50974
+ }
50975
+ async function waitForCartReveal(page, expectedProductTitle, ctx, timeoutMs) {
50976
+ const start = Date.now();
50977
+ const deadline = start + timeoutMs;
50978
+ let pollCount = 0;
50979
+ for (;; ) {
50980
+ pollCount++;
50981
+ const marker = await isCartRevealed(page, expectedProductTitle, ctx);
50982
+ if (marker) {
50983
+ dlog2(ctx, ` waitForCartReveal: revealed after ${pollCount} poll(s) (${marker})`);
50984
+ return {
50985
+ marker,
50986
+ diagnostics: { timedOut: false, budgetMs: timeoutMs, elapsedMs: Date.now() - start, pollCount }
50987
+ };
50988
+ }
50989
+ if (Date.now() >= deadline) {
50990
+ const probes = await probeCartSelectors(page, ctx);
50991
+ const diagnostics = {
50992
+ timedOut: true,
50993
+ budgetMs: timeoutMs,
50994
+ elapsedMs: Date.now() - start,
50995
+ pollCount,
50996
+ probes
50997
+ };
50998
+ dlog2(ctx, ` waitForCartReveal: ${summarizeCartRevealDiagnostics(diagnostics)}`);
50999
+ return { marker: null, diagnostics };
51000
+ }
51001
+ await page.waitForTimeout(CART_REVEAL_POLL_INTERVAL_MS);
51002
+ }
51003
+ }
50869
51004
  async function validateCartContainsTitleQuick(page, expectedTitle, ctx) {
50870
51005
  const quickSelectors = [
50871
51006
  ...ctx ? selFor(ctx, "minicartPanel") : [],
@@ -50903,7 +51038,9 @@ async function waitForCartHydration(page) {
50903
51038
  }
50904
51039
  async function openMinicart(page, trigger, ctx, expectedProductTitle) {
50905
51040
  const beforeUrl = page.url();
50906
- dlog2(ctx, ` openMinicart: starting — trigger=${trigger.selector} title=${expectedProductTitle?.slice(0, 40) ?? "none"}`);
51041
+ let lastDiagnostics;
51042
+ const revealBudget = ctx.rc.cartRevealTimeoutMs ?? (isLocalhost(beforeUrl) ? CART_REVEAL_BUDGET_LOCALHOST_MS : CART_REVEAL_BUDGET_MS);
51043
+ dlog2(ctx, ` openMinicart: starting — trigger=${trigger.selector} title=${expectedProductTitle?.slice(0, 40) ?? "none"} revealBudget=${revealBudget}ms`);
50907
51044
  await Promise.race([
50908
51045
  dismissOverlays(page, ctx),
50909
51046
  new Promise((resolve) => setTimeout(resolve, 4000))
@@ -50922,11 +51059,12 @@ async function openMinicart(page, trigger, ctx, expectedProductTitle) {
50922
51059
  await trigger.locator.hover({ timeout: 3000 }).catch(() => {
50923
51060
  return;
50924
51061
  });
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 };
51062
+ const hoverProbeBudget = Math.min(CART_REVEAL_HOVER_PROBE_MS, revealBudget);
51063
+ const hover1 = await waitForCartReveal(page, expectedProductTitle, ctx, hoverProbeBudget);
51064
+ lastDiagnostics = hover1.diagnostics;
51065
+ if (hover1.marker) {
51066
+ dlog2(ctx, ` openMinicart: hover opened drawer (${hover1.marker})`);
51067
+ return { method: "hover", url: page.url(), visibleMarker: hover1.marker, diagnostics: hover1.diagnostics };
50930
51068
  }
50931
51069
  }
50932
51070
  if (ctx.viewport === "mobile") {
@@ -50947,11 +51085,11 @@ async function openMinicart(page, trigger, ctx, expectedProductTitle) {
50947
51085
  dlog2(ctx, ` openMinicart: tap navigated → ${page.url()} (settled)`);
50948
51086
  return { method: "click-navigate", url: page.url(), visibleMarker: null };
50949
51087
  }
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 };
51088
+ const tap = await waitForCartReveal(page, expectedProductTitle, ctx, revealBudget);
51089
+ lastDiagnostics = tap.diagnostics;
51090
+ if (tap.marker) {
51091
+ dlog2(ctx, ` openMinicart: tap opened drawer (${tap.marker})`);
51092
+ return { method: "click", url: page.url(), visibleMarker: tap.marker, diagnostics: tap.diagnostics };
50955
51093
  }
50956
51094
  }
50957
51095
  dlog2(ctx, ` openMinicart: trying force-click on ${trigger.selector}${triggerHref ? ` (href=${triggerHref})` : ""}`);
@@ -50972,22 +51110,35 @@ async function openMinicart(page, trigger, ctx, expectedProductTitle) {
50972
51110
  dlog2(ctx, ` openMinicart: click navigated → ${page.url()} (settled)`);
50973
51111
  return { method: "click-navigate", url: page.url(), visibleMarker: null };
50974
51112
  }
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 };
51113
+ const click = await waitForCartReveal(page, expectedProductTitle, ctx, revealBudget);
51114
+ lastDiagnostics = click.diagnostics;
51115
+ if (click.marker) {
51116
+ dlog2(ctx, ` openMinicart: click opened drawer (${click.marker})`);
51117
+ return { method: "click", url: afterClickUrl, visibleMarker: click.marker, diagnostics: click.diagnostics };
51118
+ }
51119
+ const blockingOverlay = await dismissBlockingOverlay(page, ctx, trigger.locator);
51120
+ if (blockingOverlay?.dismissed) {
51121
+ dlog2(ctx, ` openMinicart: cleared blocking overlay (${blockingOverlay.method}) — retrying click`);
51122
+ await trigger.locator.click({ force: true, timeout: 4000 }).catch(() => {
51123
+ return;
51124
+ });
51125
+ const retry = await waitForCartReveal(page, expectedProductTitle, ctx, revealBudget);
51126
+ lastDiagnostics = retry.diagnostics;
51127
+ if (retry.marker) {
51128
+ dlog2(ctx, ` openMinicart: click opened drawer after overlay dismissal (${retry.marker})`);
51129
+ return { method: "click", url: page.url(), visibleMarker: retry.marker, diagnostics: retry.diagnostics };
51130
+ }
50980
51131
  }
50981
51132
  if (ctx.viewport !== "desktop") {
50982
51133
  dlog2(ctx, ` openMinicart: click didn't reveal cart, trying hover (mobile)`);
50983
51134
  await trigger.locator.hover({ timeout: 3000 }).catch(() => {
50984
51135
  return;
50985
51136
  });
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 };
51137
+ const hover2 = await waitForCartReveal(page, expectedProductTitle, ctx, revealBudget);
51138
+ lastDiagnostics = hover2.diagnostics;
51139
+ if (hover2.marker) {
51140
+ dlog2(ctx, ` openMinicart: hover opened drawer (${hover2.marker})`);
51141
+ return { method: "hover", url: page.url(), visibleMarker: hover2.marker, diagnostics: hover2.diagnostics };
50991
51142
  }
50992
51143
  }
50993
51144
  if (hrefHasCartTarget && triggerHref) {
@@ -51010,8 +51161,8 @@ async function openMinicart(page, trigger, ctx, expectedProductTitle) {
51010
51161
  }
51011
51162
  }
51012
51163
  }
51013
- dlog2(ctx, " openMinicart: failed — no cart revealed by hover/click/goto");
51014
- return { method: "failed", url: page.url(), visibleMarker: null };
51164
+ dlog2(ctx, ` openMinicart: failed — no cart revealed by hover/click/goto${lastDiagnostics ? ` (${summarizeCartRevealDiagnostics(lastDiagnostics)})` : ""}`);
51165
+ return { method: "failed", url: page.url(), visibleMarker: null, diagnostics: lastDiagnostics };
51015
51166
  }
51016
51167
  function normalizeTitle(s) {
51017
51168
  return s.toLowerCase().replace(/[®©™]/g, "").replace(/[^\p{L}\p{N}\s]+/gu, " ").replace(/\s+/g, " ").trim();
@@ -51868,6 +52019,7 @@ async function flowPurchaseJourney(ctx) {
51868
52019
  usedSelector: buyHit.selector,
51869
52020
  recoveredByLlm: buyRecovered || undefined,
51870
52021
  note: validation.status === "ok" ? undefined : validation.note,
52022
+ diagnostics: validation.diagnostics,
51871
52023
  detail: {
51872
52024
  signal: validation.signal,
51873
52025
  errorText: validation.errorText,
@@ -51897,13 +52049,26 @@ async function flowPurchaseJourney(ctx) {
51897
52049
  let cartOpenMethod = "failed";
51898
52050
  let miniText = "";
51899
52051
  let cartRevealMode = "unknown";
52052
+ let revealDiagnostics;
51900
52053
  const drawerAlreadyOpen = await isCartRevealed(page, expectedProductTitle, ctx) !== null;
51901
52054
  if (miniHit) {
51902
52055
  miniText = await miniHit.locator.innerText().catch(() => "");
51903
52056
  cartRevealMode = await detectCartRevealMode(page, miniHit.locator, drawerAlreadyOpen, ctx.viewport).catch(() => "unknown");
51904
52057
  dlog2(ctx, `step 7 open-minicart: cartRevealMode=${cartRevealMode}`);
51905
- const openResult = await openMinicart(page, miniHit, ctx, expectedProductTitle);
52058
+ let openResult = await openMinicart(page, miniHit, ctx, expectedProductTitle);
51906
52059
  cartOpenMethod = openResult.method;
52060
+ revealDiagnostics = openResult.diagnostics;
52061
+ if (cartOpenMethod === "failed" && revealDiagnostics && budget.remaining > 0) {
52062
+ 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));
52063
+ if (recovery) {
52064
+ budget.remaining--;
52065
+ miniHit = recovery;
52066
+ miniRecovered = true;
52067
+ openResult = await openMinicart(page, miniHit, ctx, expectedProductTitle);
52068
+ cartOpenMethod = openResult.method;
52069
+ revealDiagnostics = openResult.diagnostics;
52070
+ }
52071
+ }
51907
52072
  } else {
51908
52073
  if (drawerAlreadyOpen) {
51909
52074
  cartOpenMethod = "already-open";
@@ -51938,6 +52103,7 @@ async function flowPurchaseJourney(ctx) {
51938
52103
  const isProdCartEmptyQuirk = ctx.acceptProdQuirks === true && ctx.side === "prod" && step7Validation !== undefined && !step7Validation.found && cartEmptyReason.startsWith("cart genuinely empty");
51939
52104
  const step7Status = isProdCartEmptyQuirk ? "skipped" : cartOpenMethod === "failed" ? "failed" : step7Validation && !step7Validation.found ? "failed" : "ok";
51940
52105
  const step7QuirkNote = isProdCartEmptyQuirk ? "cart-empty-prod-quirk: aceito via --accept-prod-quirks (cartRevealMode validated by separate check)" : undefined;
52106
+ const step7RevealFailureNote = cartOpenMethod === "failed" && revealDiagnostics ? `minicart não revelou — ${summarizeCartRevealDiagnostics(revealDiagnostics)}` : undefined;
51941
52107
  steps.push({
51942
52108
  step: 7,
51943
52109
  name: "open-minicart",
@@ -51952,13 +52118,14 @@ async function flowPurchaseJourney(ctx) {
51952
52118
  cartOpenMethod,
51953
52119
  cartRevealMode,
51954
52120
  cartValidation: step7Validation,
51955
- note: step7QuirkNote,
52121
+ diagnostics: revealDiagnostics,
52122
+ note: step7QuirkNote ?? step7RevealFailureNote,
51956
52123
  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
52124
  selectorKey: miniHit ? "minicartTrigger" : undefined,
51958
52125
  usedSelector: miniHit?.selector,
51959
52126
  recoveredByLlm: miniRecovered || undefined
51960
52127
  });
51961
- reportEnd(7, "open-minicart", step7Status, Date.now() - t7, step7QuirkNote);
52128
+ reportEnd(7, "open-minicart", step7Status, Date.now() - t7, step7QuirkNote ?? step7RevealFailureNote);
51962
52129
  if (isProdCartEmptyQuirk) {
51963
52130
  const quirkNote = "cart-empty-prod-quirk: skipped (depende do cart que prod não persistiu)";
51964
52131
  reportStart(8, "shipping-calc-cart");
@@ -52299,9 +52466,22 @@ async function findPlusButtonNear(input) {
52299
52466
  return null;
52300
52467
  }
52301
52468
  async function validateAddToCart(page, ctx, cartCountBefore, beforeUrl) {
52302
- const deadline = Date.now() + resolveAddToCartConfirmMs(ctx.rc);
52469
+ const startedAt = Date.now();
52470
+ const budgetMs = resolveAddToCartConfirmMs(ctx.rc);
52471
+ const deadline = startedAt + budgetMs;
52303
52472
  let lastErrorText;
52473
+ let pollCount = 0;
52474
+ const drawerSelectors = [
52475
+ ...selFor(ctx, "cartOpenedIndicator"),
52476
+ "[role='dialog']",
52477
+ "[data-minicart][aria-expanded='true']",
52478
+ "[data-cart-drawer].open",
52479
+ "[data-minicart-drawer]:not([hidden])",
52480
+ ".minicart--open",
52481
+ ".cart-drawer--open"
52482
+ ];
52304
52483
  while (Date.now() < deadline) {
52484
+ pollCount++;
52305
52485
  const currentUrl = page.url();
52306
52486
  if (currentUrl !== beforeUrl && /\/(cart|carrinho|checkout)(\/|$|\?)/i.test(currentUrl)) {
52307
52487
  return {
@@ -52318,15 +52498,6 @@ async function validateAddToCart(page, ctx, cartCountBefore, beforeUrl) {
52318
52498
  note: `Contador do minicart foi de ${cartCountBefore} para ${cartCountNow}`
52319
52499
  };
52320
52500
  }
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
52501
  for (const sel of drawerSelectors) {
52331
52502
  const el = page.locator(sel).first();
52332
52503
  if (await el.isVisible({ timeout: 200 }).catch(() => false)) {
@@ -52359,18 +52530,32 @@ async function validateAddToCart(page, ctx, cartCountBefore, beforeUrl) {
52359
52530
  } catch {}
52360
52531
  await page.waitForTimeout(250);
52361
52532
  }
52533
+ const diagnostics = {
52534
+ timedOut: true,
52535
+ budgetMs,
52536
+ elapsedMs: Date.now() - startedAt,
52537
+ pollCount,
52538
+ probes: await Promise.all(drawerSelectors.map(async (selector) => {
52539
+ const loc = page.locator(selector).first();
52540
+ const present = await loc.count().catch(() => 0) > 0;
52541
+ const visible = present ? await loc.isVisible({ timeout: 200 }).catch(() => false) : false;
52542
+ return { selector, present, visible };
52543
+ }))
52544
+ };
52362
52545
  if (lastErrorText) {
52363
52546
  return {
52364
52547
  status: "failed",
52365
52548
  signal: "error-text",
52366
52549
  note: `add-to-cart silenciosamente falhou: '${lastErrorText}' visível na página`,
52367
- errorText: lastErrorText
52550
+ errorText: lastErrorText,
52551
+ diagnostics
52368
52552
  };
52369
52553
  }
52370
52554
  return {
52371
52555
  status: "failed",
52372
52556
  signal: "no-signal",
52373
- note: "add-to-cart sem confirmação visível (minicart não atualizou, sem drawer, sem mudança de URL)"
52557
+ note: "add-to-cart sem confirmação visível (minicart não atualizou, sem drawer, sem mudança de URL)",
52558
+ diagnostics
52374
52559
  };
52375
52560
  }
52376
52561
 
@@ -54988,6 +55173,19 @@ async function e2eCommand(opts) {
54988
55173
  quiet: opts.json === true
54989
55174
  });
54990
55175
  }
55176
+ const E2E_TIMEOUT_MS = 3 * 60 * 1000;
55177
+ const watchdogScript = `setTimeout(()=>{try{process.kill(${process.pid},9)}catch(_){}},${E2E_TIMEOUT_MS})`;
55178
+ const watchdog = spawn(process.execPath, ["-e", watchdogScript], {
55179
+ stdio: "ignore",
55180
+ detached: false
55181
+ });
55182
+ watchdog.unref();
55183
+ const onSignal = () => {
55184
+ watchdog.kill();
55185
+ process.exit(1);
55186
+ };
55187
+ process.once("SIGINT", onSignal);
55188
+ process.once("SIGTERM", onSignal);
54991
55189
  const spinner = opts.json ? null : ora4("Lançando browser…").start();
54992
55190
  let browser = null;
54993
55191
  const allFlows = [];
@@ -55031,6 +55229,9 @@ async function e2eCommand(opts) {
55031
55229
  if (spinner)
55032
55230
  spinner.succeed(`${allFlows.length} flow capture(s), ${allPages.length} página(s)`);
55033
55231
  } finally {
55232
+ watchdog.kill();
55233
+ process.off("SIGINT", onSignal);
55234
+ process.off("SIGTERM", onSignal);
55034
55235
  await browser?.close().catch(() => {
55035
55236
  return;
55036
55237
  });
@@ -58406,17 +58607,6 @@ function formatSecs(ms) {
58406
58607
  return `${min}m${sec.toString().padStart(2, "0")}s`;
58407
58608
  }
58408
58609
 
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
58610
  // src/util/timing.ts
58421
58611
  async function withTiming(fn) {
58422
58612
  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.1",
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",