@decocms/parity 0.13.0 → 0.15.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 +14 -0
  2. package/dist/cli.js +217 -23
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.15.0](https://github.com/decocms/parity/compare/v0.14.0...v0.15.0) (2026-07-27)
11
+
12
+ ### Added
13
+
14
+ * **Structural overlay interception detection + configurable overlay selectors (issues #145, #146).** A "no-signal" add-to-cart failure is often not a broken cart but a newsletter/discount modal intercepting the click — classically one that pops on the very `mousemove` Playwright's `.click()` dispatches, in a cookie-less session (e.g. a `div.fixed bg-black/50 z-9999` backdrop over the buy button). Instead of name-matching every popup shape (whack-a-mole), parity now detects interception **structurally**: before retrying, it checks whether the buy button is still the topmost element at its click point (`document.elementFromPoint`) and, if something covers it, dismisses it least-destructive-first — Escape → a named close button → an icon/geometry close control inside the overlay (catches unnamed `<button><svg/></button>` X's) → a corner backdrop click → and, guarded, hiding the confirmed interceptor as a last resort (never an ancestor of the target, and it can't fake success — the step still needs a real confirmation signal) — then retries once. What was detected/dismissed is recorded in the step's `detail.overlayDismissed` and surfaced in the action text, so a report shows *why* a click was intercepted even when dismissal succeeded. Separately, `.parityrc.json` `overlaySelectors` adds explicit site-specific overlay selectors, merged with the built-in cookie/toast/newsletter defaults (never replacing them), as a fast-path override.
15
+
16
+ ## [0.14.0](https://github.com/decocms/parity/compare/v0.13.0...v0.14.0) (2026-07-27)
17
+
18
+ ### Added
19
+
20
+ * **Configurable add-to-cart confirmation deadline (issue #143).** The purchase-journey / `e2e` add-to-cart step polled a hardcoded 3000ms for a success signal (URL→cart, minicart count increase, drawer open, success toast). On sites whose success toast is short-lived, or with slow TTFB / popup overlays, that could race the deadline and report a false "no signal" failure on an add-to-cart that actually worked. Now tunable via `.parityrc.json` `addToCartConfirmMs` or `--add-to-cart-timeout <ms>` (on `parity run` and `parity e2e`); a non-positive/NaN override is ignored and falls back to the 3000ms default.
21
+
22
+ ## [0.13.0](https://github.com/decocms/parity/compare/v0.12.0...v0.13.0) (2026-07-27)
23
+
10
24
  ### Added
11
25
 
12
26
  * **`parity e2e` now automates selectors like `parity run` (issue #141).** Single-site runs previously saw only `DEFAULT_SELECTORS` + hand-written `.parityrc.json` — `e2e` never detected the platform, never ran LLM selector discovery, and never learned from its flow runs (so `learned-selectors.json`, keyed by platform, never applied). It now detects the platform, runs the same grounded LLM discovery + live-validation pass, threads the platform into every flow, and promotes selectors learned from real successful interactions. New flags mirror `run`: `--no-auto-selectors`, `--refresh-selectors`, `--no-learn`. The discovery pass is now shared code (`src/engine/selector-discovery-pass.ts`) used by both commands.
package/dist/cli.js CHANGED
@@ -41433,7 +41433,9 @@ var ParityRc = z.object({
41433
41433
  validCode: z.string().optional()
41434
41434
  }).optional(),
41435
41435
  serverFnFloodBudget: z.number().optional(),
41436
- serverFnPattern: z.string().optional()
41436
+ serverFnPattern: z.string().optional(),
41437
+ overlaySelectors: z.array(z.string()).optional(),
41438
+ addToCartConfirmMs: z.number().optional()
41437
41439
  });
41438
41440
  var ParityIgnore = z.object({
41439
41441
  ignoreSelectorsVisual: z.array(z.string()).default([]),
@@ -50530,41 +50532,206 @@ async function extractProductTitle(page) {
50530
50532
  return docTitle.trim();
50531
50533
  return null;
50532
50534
  }
50535
+ var DEFAULT_OVERLAY_SELECTORS = [
50536
+ "[class*='cookie' i][class*='banner' i]",
50537
+ "[class*='cookie' i][class*='consent' i]",
50538
+ "[id*='cookie' i][class*='banner' i]",
50539
+ "[role='alertdialog']:visible",
50540
+ "[class*='toast' i]:visible",
50541
+ "[class*='snackbar' i]:visible",
50542
+ "[class*='added-to-cart' i]:visible",
50543
+ "[class*='product-added' i]:visible",
50544
+ "[id*='newsletter' i]:visible",
50545
+ "[class*='newsletter' i]:visible",
50546
+ "[id*='signup-popup' i]:visible",
50547
+ "[class*='signup' i][class*='popup' i]:visible"
50548
+ ];
50549
+ var CLOSE_BUTTON_SELECTOR = "button[aria-label*='close' i], [aria-label*='close' i], button[aria-label*='fechar' i], [aria-label*='fechar' i], [data-close], [data-dismiss], [data-qa-dismiss], button[class*='close' i], button:has-text('×'), button:has-text('✕')";
50550
+ function overlaySelectorsFor(rc) {
50551
+ const extra = rc?.overlaySelectors ?? [];
50552
+ return [...new Set([...DEFAULT_OVERLAY_SELECTORS, ...extra])];
50553
+ }
50554
+ async function blockerAtTarget(target) {
50555
+ return await withCap(target.evaluate((el) => {
50556
+ const node2 = el;
50557
+ const rect = node2.getBoundingClientRect();
50558
+ if (rect.width === 0 || rect.height === 0)
50559
+ return null;
50560
+ const cx = rect.left + rect.width / 2;
50561
+ const cy = rect.top + rect.height / 2;
50562
+ const top = document.elementFromPoint(cx, cy);
50563
+ if (!top || top === node2 || node2.contains(top))
50564
+ return null;
50565
+ let cur = top;
50566
+ let overlay = top;
50567
+ while (cur && cur !== document.body) {
50568
+ const pos = getComputedStyle(cur).position;
50569
+ if (pos === "fixed" || pos === "sticky" || pos === "absolute")
50570
+ overlay = cur;
50571
+ cur = cur.parentElement;
50572
+ }
50573
+ const r = overlay.getBoundingClientRect();
50574
+ const coverage = r.width * r.height / (window.innerWidth * window.innerHeight);
50575
+ const cls = typeof overlay.className === "string" ? overlay.className : "";
50576
+ return {
50577
+ tag: overlay.tagName.toLowerCase(),
50578
+ id: overlay.id || undefined,
50579
+ className: cls ? cls.slice(0, 120) : undefined,
50580
+ fullViewport: coverage > 0.6
50581
+ };
50582
+ }).catch(() => null), 2000, null);
50583
+ }
50584
+ async function findOverlayCloseControl(target) {
50585
+ return await withCap(target.evaluate((el) => {
50586
+ const node2 = el;
50587
+ const rect = node2.getBoundingClientRect();
50588
+ if (rect.width === 0 || rect.height === 0)
50589
+ return null;
50590
+ const cx = rect.left + rect.width / 2;
50591
+ const cy = rect.top + rect.height / 2;
50592
+ const top = document.elementFromPoint(cx, cy);
50593
+ if (!top || top === node2 || node2.contains(top))
50594
+ return null;
50595
+ let cur = top;
50596
+ let overlay = top;
50597
+ while (cur && cur !== document.body) {
50598
+ const pos = getComputedStyle(cur).position;
50599
+ if (pos === "fixed" || pos === "sticky" || pos === "absolute")
50600
+ overlay = cur;
50601
+ cur = cur.parentElement;
50602
+ }
50603
+ const orect = overlay.getBoundingClientRect();
50604
+ const clickables = Array.from(overlay.querySelectorAll("button, a, [role='button']"));
50605
+ let best = null;
50606
+ for (const c of clickables) {
50607
+ const r = c.getBoundingClientRect();
50608
+ if (r.width === 0 || r.height === 0 || r.width > 72 || r.height > 72)
50609
+ continue;
50610
+ const label = `${c.getAttribute("aria-label") ?? ""} ${typeof c.className === "string" ? c.className : ""} ${c.textContent ?? ""}`.toLowerCase();
50611
+ const named = /close|fechar|dismiss|×|✕/.test(label);
50612
+ const distTopRight = Math.hypot(orect.right - r.right, r.top - orect.top);
50613
+ const score = (named ? 0 : 1e5) + distTopRight;
50614
+ if (!best || score < best.score) {
50615
+ best = { x: r.left + r.width / 2, y: r.top + r.height / 2, score };
50616
+ }
50617
+ }
50618
+ return best ? { x: best.x, y: best.y } : null;
50619
+ }).catch(() => null), 2000, null);
50620
+ }
50621
+ async function neutralizeBlockerAtTarget(target) {
50622
+ return await withCap(target.evaluate((el) => {
50623
+ const node2 = el;
50624
+ const rect = node2.getBoundingClientRect();
50625
+ if (rect.width === 0 || rect.height === 0)
50626
+ return false;
50627
+ const cx = rect.left + rect.width / 2;
50628
+ const cy = rect.top + rect.height / 2;
50629
+ const top = document.elementFromPoint(cx, cy);
50630
+ if (!top || top === node2 || node2.contains(top))
50631
+ return false;
50632
+ let cur = top;
50633
+ let overlay = top;
50634
+ while (cur && cur !== document.body) {
50635
+ const pos = getComputedStyle(cur).position;
50636
+ if (pos === "fixed" || pos === "sticky" || pos === "absolute")
50637
+ overlay = cur;
50638
+ cur = cur.parentElement;
50639
+ }
50640
+ if (overlay.contains(node2))
50641
+ return false;
50642
+ overlay.style.setProperty("display", "none", "important");
50643
+ return true;
50644
+ }).catch(() => false), 2000, false);
50645
+ }
50646
+ async function dismissBlockingOverlay(page, ctx, target) {
50647
+ const blocker = await blockerAtTarget(target);
50648
+ if (!blocker)
50649
+ return null;
50650
+ const base = {
50651
+ reason: "click-point-intercepted",
50652
+ tag: blocker.tag,
50653
+ id: blocker.id,
50654
+ className: blocker.className,
50655
+ fullViewport: blocker.fullViewport
50656
+ };
50657
+ const cleared = async () => await blockerAtTarget(target) === null;
50658
+ await page.keyboard.press("Escape").catch(() => {
50659
+ return;
50660
+ });
50661
+ await page.waitForTimeout(300);
50662
+ if (await cleared()) {
50663
+ dlog2(ctx, ` overlay: dismissed via Escape (${describe(blocker)})`);
50664
+ return { ...base, method: "escape", dismissed: true };
50665
+ }
50666
+ const closer = page.locator(CLOSE_BUTTON_SELECTOR).first();
50667
+ if (await withCap(closer.isVisible({ timeout: 300 }).catch(() => false), 500, false)) {
50668
+ await closer.click({ timeout: 1500 }).catch(() => {
50669
+ return;
50670
+ });
50671
+ await page.waitForTimeout(300);
50672
+ if (await cleared()) {
50673
+ dlog2(ctx, ` overlay: dismissed via close button (${describe(blocker)})`);
50674
+ return { ...base, method: "close-button", dismissed: true };
50675
+ }
50676
+ }
50677
+ const closeXY = await findOverlayCloseControl(target);
50678
+ if (closeXY) {
50679
+ await page.mouse.click(closeXY.x, closeXY.y).catch(() => {
50680
+ return;
50681
+ });
50682
+ await page.waitForTimeout(300);
50683
+ if (await cleared()) {
50684
+ dlog2(ctx, ` overlay: dismissed via icon close control (${describe(blocker)})`);
50685
+ return { ...base, method: "close-button", dismissed: true };
50686
+ }
50687
+ }
50688
+ await page.mouse.click(5, 5).catch(() => {
50689
+ return;
50690
+ });
50691
+ await page.waitForTimeout(300);
50692
+ if (await cleared()) {
50693
+ dlog2(ctx, ` overlay: dismissed via backdrop click (${describe(blocker)})`);
50694
+ return { ...base, method: "backdrop-click", dismissed: true };
50695
+ }
50696
+ if (await neutralizeBlockerAtTarget(target)) {
50697
+ await page.waitForTimeout(150);
50698
+ if (await cleared()) {
50699
+ dlog2(ctx, ` overlay: neutralized (hidden) as last resort (${describe(blocker)})`);
50700
+ return { ...base, method: "neutralized", dismissed: true };
50701
+ }
50702
+ }
50703
+ dlog2(ctx, ` overlay: detected but NOT dismissed (${describe(blocker)})`);
50704
+ return { ...base, method: "backdrop-click", dismissed: false };
50705
+ }
50706
+ function describe(b) {
50707
+ return `${b.tag}${b.id ? `#${b.id}` : ""}${b.className ? `.${b.className.split(/\s+/)[0]}` : ""}`;
50708
+ }
50533
50709
  async function dismissOverlays(page, ctx) {
50534
- const overlaySelectors = [
50535
- "[class*='cookie' i][class*='banner' i]",
50536
- "[class*='cookie' i][class*='consent' i]",
50537
- "[id*='cookie' i][class*='banner' i]",
50538
- "[role='alertdialog']:visible",
50539
- "[class*='toast' i]:visible",
50540
- "[class*='snackbar' i]:visible",
50541
- "[class*='added-to-cart' i]:visible",
50542
- "[class*='product-added' i]:visible"
50543
- ];
50544
- let dismissedAny = false;
50545
- for (const sel of overlaySelectors) {
50710
+ const dismissals = [];
50711
+ for (const sel of overlaySelectorsFor(ctx.rc)) {
50546
50712
  try {
50547
50713
  const overlay = page.locator(sel).first();
50548
50714
  if (!await withCap(overlay.isVisible({ timeout: 200 }).catch(() => false), 400, false))
50549
50715
  continue;
50550
- const closer = overlay.locator("button[aria-label*='close' i], button[aria-label*='fechar' i], button[class*='close' i], [data-close], [aria-label='Close']").first();
50716
+ const closer = overlay.locator(CLOSE_BUTTON_SELECTOR).first();
50551
50717
  if (await withCap(closer.isVisible({ timeout: 200 }).catch(() => false), 400, false)) {
50552
50718
  await closer.click({ timeout: 1500 }).catch(() => {
50553
50719
  return;
50554
50720
  });
50555
- dismissedAny = true;
50721
+ dismissals.push({ reason: "selector-match", tag: sel, method: "close-button", dismissed: true });
50556
50722
  continue;
50557
50723
  }
50558
50724
  await page.keyboard.press("Escape").catch(() => {
50559
50725
  return;
50560
50726
  });
50561
- dismissedAny = true;
50727
+ dismissals.push({ reason: "selector-match", tag: sel, method: "escape", dismissed: true });
50562
50728
  } catch {}
50563
50729
  }
50564
- if (dismissedAny) {
50565
- dlog2(ctx, " openMinicart: dismissed overlay(s) before interacting");
50730
+ if (dismissals.length > 0) {
50731
+ dlog2(ctx, ` dismissed ${dismissals.length} overlay(s) before interacting`);
50566
50732
  await page.waitForTimeout(500);
50567
50733
  }
50734
+ return dismissals;
50568
50735
  }
50569
50736
 
50570
50737
  // src/engine/flows/cart-helpers.ts
@@ -51331,6 +51498,11 @@ async function countProductCards(page) {
51331
51498
 
51332
51499
  // src/engine/flows/purchase-journey.ts
51333
51500
  var PURCHASE_JOURNEY_TOTAL_STEPS = 9;
51501
+ var DEFAULT_ADD_TO_CART_CONFIRM_MS = 3000;
51502
+ function resolveAddToCartConfirmMs(rc) {
51503
+ const v = rc.addToCartConfirmMs;
51504
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : DEFAULT_ADD_TO_CART_CONFIRM_MS;
51505
+ }
51334
51506
  async function flowPurchaseJourney(ctx) {
51335
51507
  const pages = [];
51336
51508
  const steps = [];
@@ -51625,10 +51797,25 @@ async function flowPurchaseJourney(ctx) {
51625
51797
  validation = await validateAddToCart(page, ctx, cartCountBefore, beforeUrl6);
51626
51798
  }
51627
51799
  }
51800
+ let overlayDismissed;
51801
+ if (validation.status === "failed" && validation.signal === "no-signal") {
51802
+ const od = await dismissBlockingOverlay(page, ctx, buyHit.locator);
51803
+ if (od) {
51804
+ overlayDismissed = od;
51805
+ if (od.dismissed) {
51806
+ await page.waitForTimeout(300);
51807
+ await buyHit.locator.click({ timeout: 5000 }).catch(() => {
51808
+ return;
51809
+ });
51810
+ validation = await validateAddToCart(page, ctx, cartCountBefore, beforeUrl6);
51811
+ }
51812
+ }
51813
+ }
51628
51814
  const sp6 = screenshotPath(ctx, "pj-6-add-cart");
51629
51815
  await screenshotStable(page, { path: sp6, fullPage: false });
51630
51816
  const buyActionDesc = `Clicou no botão${buyText ? ` '${buyText.slice(0, 40).trim()}'` : ""} (\`${buyHit.selector}\`)${buyRecovered ? " — selector veio de recovery LLM" : ""}`;
51631
- const fullActionDesc = variantRetryNote ? `${buyActionDesc} — ${variantRetryNote} ${validation.note}` : `${buyActionDesc} ${validation.note}`;
51817
+ const overlayNote = overlayDismissed ? `overlay ${overlayDismissed.tag}${overlayDismissed.id ? `#${overlayDismissed.id}` : ""} ${overlayDismissed.dismissed ? `dismissed (${overlayDismissed.method}), click retried` : "detected but NOT dismissed"}` : "";
51818
+ const fullActionDesc = variantRetryNote ? `${buyActionDesc} — ${variantRetryNote}${overlayNote} — ${validation.note}` : `${buyActionDesc}${overlayNote} — ${validation.note}`;
51632
51819
  steps.push({
51633
51820
  step: 6,
51634
51821
  name: "add-to-cart",
@@ -51648,7 +51835,8 @@ async function flowPurchaseJourney(ctx) {
51648
51835
  detail: {
51649
51836
  signal: validation.signal,
51650
51837
  errorText: validation.errorText,
51651
- variantRetry: variantRetryNote
51838
+ variantRetry: variantRetryNote,
51839
+ overlayDismissed
51652
51840
  }
51653
51841
  });
51654
51842
  reportEnd(6, "add-to-cart", validation.status, Date.now() - t6, validation.status === "ok" ? undefined : validation.note);
@@ -52075,7 +52263,7 @@ async function findPlusButtonNear(input) {
52075
52263
  return null;
52076
52264
  }
52077
52265
  async function validateAddToCart(page, ctx, cartCountBefore, beforeUrl) {
52078
- const deadline = Date.now() + 3000;
52266
+ const deadline = Date.now() + resolveAddToCartConfirmMs(ctx.rc);
52079
52267
  let lastErrorText;
52080
52268
  while (Date.now() < deadline) {
52081
52269
  const currentUrl = page.url();
@@ -54708,6 +54896,9 @@ async function e2eCommand(opts) {
54708
54896
  }
54709
54897
  const rc = loadParityRc();
54710
54898
  rc.cep = opts.cep || rc.cep;
54899
+ if (typeof opts.addToCartTimeout === "number" && Number.isFinite(opts.addToCartTimeout)) {
54900
+ rc.addToCartConfirmMs = opts.addToCartTimeout;
54901
+ }
54711
54902
  if (opts.searchTerms) {
54712
54903
  rc.search = {
54713
54904
  ...rc.search ?? {},
@@ -58746,6 +58937,9 @@ async function runCommand(rawOpts) {
58746
58937
  const failOn = opts.failOn.split(",").map((s) => s.trim()).filter(Boolean);
58747
58938
  const rc = loadParityRc();
58748
58939
  rc.cep = opts.cep || rc.cep;
58940
+ if (typeof opts.addToCartTimeout === "number" && Number.isFinite(opts.addToCartTimeout)) {
58941
+ rc.addToCartConfirmMs = opts.addToCartTimeout;
58942
+ }
58749
58943
  const ignore = loadParityIgnore();
58750
58944
  if (opts.clearCache) {
58751
58945
  const cacheFile = join18(opts.output, "cache", "verdicts.json");
@@ -60218,7 +60412,7 @@ program.command("run").description([
60218
60412
  " auto-selectors=ON (if LLM), learn=ON, cache=ON, visual-diff=ON,",
60219
60413
  " warmup=OFF, bypass-cache=OFF, ci=OFF."
60220
60414
  ].join(`
60221
- `)).option("--prod <url>", "Production URL (source of truth, e.g. Fresh site). Omit for a single site — the run will point you to `parity e2e` (issue #141).").requiredOption("--cand <url>", "Candidate URL (migrated site, e.g. TanStack)").option("--preset <name>", "Bundle of defaults: smoke (fast, ~30s, no LLM) | full (deep audit) | ci (CI-tuned)").option("--flows <list>", "Comma-separated flows: homepage,plp,pdp,purchase-journey", "purchase-journey").option("--viewports <list>", "Comma-separated viewports: mobile,desktop", "mobile,desktop").option("--cep <cep>", "CEP for shipping calculation", "01310-100").option("--runs <n>", "Repeat each measurement N times (median)", "1").option("--baseline <name>", "Compare against a saved baseline").option("--output <dir>", "Output directory", "./parity-output").option("--ci", "CI mode: stricter exit codes", false).option("--fail-on <severities>", "Comma-separated severities that cause exit 1", "critical").option("--open", "Open the HTML report after the run completes", false).option("--no-auto-selectors", "Disable LLM-based selector discovery (uses defaults instead)").option("--refresh-selectors", "Bypass selector cache and re-run discovery", false).option("--no-learn", "Don't write to learned-selectors.json (read-only mode)").option("--vitals-pages <n>", "Extra pages from sitemap to crawl for Vitals coverage (default 10)", (v) => Number(v), 10).option("--max-viewport-concurrency <n>", "How many viewports run concurrently during collect (default 2: mobile+desktop together). Set to 1 if you hit OOM with many viewports.", (v) => Number(v), 2).option("--visual-pages <n>", "Pages to compare visually via LLM (home + sampled PLPs/PDPs from sitemap, default 5)", (v) => Number(v), 5).option("--pages <list>", 'Comma-separated paths to compare visually (overrides sitemap discovery). E.g. "/,/account,/p/some-product". Use this when you want deterministic coverage instead of sampled.').option("--pages-file <path>", "Read paths to compare visually from a text file (one path per line). Lines starting with # are ignored. Overrides --pages when both are present.").option("--no-visual-diff", "Skip the visual diff capture pass entirely").option("--no-cache", "Disable the cross-run visual-diff cache (forces a fresh LLM judgment on every page).").option("--clear-cache", "Wipe the visual-diff verdict cache (parity-output/cache/verdicts.json) before the run.", false).option("--bypass-cache", "Bypass CDN/edge caches: append a cache-busting query param and send Cache-Control: no-cache on every request. Use right after a deploy to avoid false failures from stale CF edge content.", false).option("--warmup", "Before measurement, hit each target URL once (per viewport) with a cache-buster so the Worker serves a fresh response. Recommended after deploys.", false).option("--accept-prod-quirks", "Demote prod-side cart-empty journey failures (VTEX session quirk) from failed to skipped. The cart-reveal-mode-divergence check still emits critical if prod/cand markup intents differ, so this flag never masks a real regression. See issue #12.", false).option("--llm-timeout <seconds>", "Hard timeout for the LLM aggregation call (seconds). The run completes in offline mode if the LLM hangs past this. Default: 60.", (v) => Number(v), 60).option("--timeout <minutes>", "Hard wall-clock cap for the whole run (minutes). On expiry, parity writes a partial report and exits 130. Default: 30. Issue #56.", (v) => Number(v), 30).option("--flow <name>", "Alias for --flows when running a single flow (e.g. --flow cart). Issue #53.").option("--json <path|->", "Emit JSON-Lines (one line per check) to the given file path, or '-' for stdout. Schema versioned via leading metadata line. Issue #53.").option("--pt", "Tell the LLM to respond in Brazilian Portuguese. Only affects LLM-generated content (issues, explain, prompts) — the static HTML report stays in English. Issue #67.").option("--llm <provider>", "Force LLM provider: anthropic | openrouter | claude-code | none. Default: auto-detect (anthropic key → openrouter key → local claude CLI → none). Issue #66.").option("--llm-model <overrides>", "Per-feature model override, e.g. visual-diff=claude-opus-4-7,explain=claude-opus-4-7. Features: selector-discovery, step-recovery, search-terms, plp-matching, pdp-matching, section-understanding, visual-diff, issue-aggregation, explain, component-detection. Issue #66.").option("--llm-tier-default <tier>", "Override the default tier (haiku | sonnet | opus) for every feature that doesn't have a per-feature override. Issue #66.").option("--llm-model-default <model>", "Force every LLM call to use this exact model ID, ignoring per-feature defaults. Issue #66.").option("--no-interactive", "Disable the interactive selector prompt that auto-fires in a TTY without an LLM provider. Use in scripts and CI where stdin is technically a TTY but you don't want to pause. Issue #72.").option("--only <modules>", "Scope the run to these modules and/or single checks (comma-separated). Module names: e2e, seo, visual, vitals, cache, console, html, network. Single-check granularity via `check:<name>`, e.g. `--only e2e,check:cache-coverage`. Run `parity list modules` for the full check/flow mapping. Default: all modules. M3 module selection.").option("--skip <modules>", "Subtract these modules and/or single checks (comma-separated, same syntax as --only) from whatever base set was chosen (all modules, or --only's set if both are given). M3 module selection.").option("--why <text>", "Free-text reason for this run's scope, stored in report.json as `selectionReason`. Purely informational. M3 module selection.").action(async (opts) => {
60415
+ `)).option("--prod <url>", "Production URL (source of truth, e.g. Fresh site). Omit for a single site — the run will point you to `parity e2e` (issue #141).").requiredOption("--cand <url>", "Candidate URL (migrated site, e.g. TanStack)").option("--preset <name>", "Bundle of defaults: smoke (fast, ~30s, no LLM) | full (deep audit) | ci (CI-tuned)").option("--flows <list>", "Comma-separated flows: homepage,plp,pdp,purchase-journey", "purchase-journey").option("--viewports <list>", "Comma-separated viewports: mobile,desktop", "mobile,desktop").option("--cep <cep>", "CEP for shipping calculation", "01310-100").option("--add-to-cart-timeout <ms>", "How long (ms) the add-to-cart step polls for a success signal before failing. Default 3000. Raise/lower to match your site's success-toast lifetime (issue #143).", (v) => Number(v)).option("--runs <n>", "Repeat each measurement N times (median)", "1").option("--baseline <name>", "Compare against a saved baseline").option("--output <dir>", "Output directory", "./parity-output").option("--ci", "CI mode: stricter exit codes", false).option("--fail-on <severities>", "Comma-separated severities that cause exit 1", "critical").option("--open", "Open the HTML report after the run completes", false).option("--no-auto-selectors", "Disable LLM-based selector discovery (uses defaults instead)").option("--refresh-selectors", "Bypass selector cache and re-run discovery", false).option("--no-learn", "Don't write to learned-selectors.json (read-only mode)").option("--vitals-pages <n>", "Extra pages from sitemap to crawl for Vitals coverage (default 10)", (v) => Number(v), 10).option("--max-viewport-concurrency <n>", "How many viewports run concurrently during collect (default 2: mobile+desktop together). Set to 1 if you hit OOM with many viewports.", (v) => Number(v), 2).option("--visual-pages <n>", "Pages to compare visually via LLM (home + sampled PLPs/PDPs from sitemap, default 5)", (v) => Number(v), 5).option("--pages <list>", 'Comma-separated paths to compare visually (overrides sitemap discovery). E.g. "/,/account,/p/some-product". Use this when you want deterministic coverage instead of sampled.').option("--pages-file <path>", "Read paths to compare visually from a text file (one path per line). Lines starting with # are ignored. Overrides --pages when both are present.").option("--no-visual-diff", "Skip the visual diff capture pass entirely").option("--no-cache", "Disable the cross-run visual-diff cache (forces a fresh LLM judgment on every page).").option("--clear-cache", "Wipe the visual-diff verdict cache (parity-output/cache/verdicts.json) before the run.", false).option("--bypass-cache", "Bypass CDN/edge caches: append a cache-busting query param and send Cache-Control: no-cache on every request. Use right after a deploy to avoid false failures from stale CF edge content.", false).option("--warmup", "Before measurement, hit each target URL once (per viewport) with a cache-buster so the Worker serves a fresh response. Recommended after deploys.", false).option("--accept-prod-quirks", "Demote prod-side cart-empty journey failures (VTEX session quirk) from failed to skipped. The cart-reveal-mode-divergence check still emits critical if prod/cand markup intents differ, so this flag never masks a real regression. See issue #12.", false).option("--llm-timeout <seconds>", "Hard timeout for the LLM aggregation call (seconds). The run completes in offline mode if the LLM hangs past this. Default: 60.", (v) => Number(v), 60).option("--timeout <minutes>", "Hard wall-clock cap for the whole run (minutes). On expiry, parity writes a partial report and exits 130. Default: 30. Issue #56.", (v) => Number(v), 30).option("--flow <name>", "Alias for --flows when running a single flow (e.g. --flow cart). Issue #53.").option("--json <path|->", "Emit JSON-Lines (one line per check) to the given file path, or '-' for stdout. Schema versioned via leading metadata line. Issue #53.").option("--pt", "Tell the LLM to respond in Brazilian Portuguese. Only affects LLM-generated content (issues, explain, prompts) — the static HTML report stays in English. Issue #67.").option("--llm <provider>", "Force LLM provider: anthropic | openrouter | claude-code | none. Default: auto-detect (anthropic key → openrouter key → local claude CLI → none). Issue #66.").option("--llm-model <overrides>", "Per-feature model override, e.g. visual-diff=claude-opus-4-7,explain=claude-opus-4-7. Features: selector-discovery, step-recovery, search-terms, plp-matching, pdp-matching, section-understanding, visual-diff, issue-aggregation, explain, component-detection. Issue #66.").option("--llm-tier-default <tier>", "Override the default tier (haiku | sonnet | opus) for every feature that doesn't have a per-feature override. Issue #66.").option("--llm-model-default <model>", "Force every LLM call to use this exact model ID, ignoring per-feature defaults. Issue #66.").option("--no-interactive", "Disable the interactive selector prompt that auto-fires in a TTY without an LLM provider. Use in scripts and CI where stdin is technically a TTY but you don't want to pause. Issue #72.").option("--only <modules>", "Scope the run to these modules and/or single checks (comma-separated). Module names: e2e, seo, visual, vitals, cache, console, html, network. Single-check granularity via `check:<name>`, e.g. `--only e2e,check:cache-coverage`. Run `parity list modules` for the full check/flow mapping. Default: all modules. M3 module selection.").option("--skip <modules>", "Subtract these modules and/or single checks (comma-separated, same syntax as --only) from whatever base set was chosen (all modules, or --only's set if both are given). M3 module selection.").option("--why <text>", "Free-text reason for this run's scope, stored in report.json as `selectionReason`. Purely informational. M3 module selection.").action(async (opts) => {
60222
60416
  if (opts.flow) {
60223
60417
  opts.flows = opts.flow;
60224
60418
  }
@@ -60232,7 +60426,7 @@ program.command("audit").description("Single-site absolute audit. Runs console +
60232
60426
  }
60233
60427
  process.exit(await auditCommand(opts));
60234
60428
  });
60235
- program.command("e2e").description("Single-site functional end-to-end. Runs all functional flows (homepage, plp, pdp, purchase-journey, search, cart-interactions, optionally login) against ONE URL, then runs all checks in single-site mode. Use for 'does this site actually work?' verification — broader than `audit` (which only does vitals/console/network/images/seo).").requiredOption("--url <url>", "Base URL of the site to test").option("--flows <list>", "Comma-separated flows. Default: homepage,plp,pdp,purchase-journey,search,cart-interactions (login is opt-in)", "").option("--viewports <list>", "Comma-separated viewports", "mobile,desktop").option("--cep <cep>", "CEP for shipping calculation", "01310-100").option("--search-terms <list>", "Comma-separated search terms to use (override LLM auto-discovery)").option("--login-email <email>", "Login email (also PARITY_LOGIN_EMAIL env var)").option("--login-password <pwd>", "Login password (also PARITY_LOGIN_PASSWORD env var)").option("--output <dir>", "Output directory", "./parity-output").option("--open", "Open the HTML report after the run completes", false).option("--json", "Emit one-line JSON instead of pretty text", false).option("--no-auto-selectors", "Disable LLM-based selector discovery (uses defaults instead)").option("--refresh-selectors", "Bypass selector cache and re-run discovery", false).option("--no-learn", "Don't write to learned-selectors.json (read-only mode)").option("--fail-on <severities>", "Comma-separated severities that cause exit 1 (default: critical,high)", "critical,high").option("--pt", "Tell the LLM to respond in Brazilian Portuguese. Issue #67.").action(async (opts) => {
60429
+ program.command("e2e").description("Single-site functional end-to-end. Runs all functional flows (homepage, plp, pdp, purchase-journey, search, cart-interactions, optionally login) against ONE URL, then runs all checks in single-site mode. Use for 'does this site actually work?' verification — broader than `audit` (which only does vitals/console/network/images/seo).").requiredOption("--url <url>", "Base URL of the site to test").option("--flows <list>", "Comma-separated flows. Default: homepage,plp,pdp,purchase-journey,search,cart-interactions (login is opt-in)", "").option("--viewports <list>", "Comma-separated viewports", "mobile,desktop").option("--cep <cep>", "CEP for shipping calculation", "01310-100").option("--add-to-cart-timeout <ms>", "How long (ms) the add-to-cart step polls for a success signal before failing. Default 3000. Raise/lower to match your site's success-toast lifetime (issue #143).", (v) => Number(v)).option("--search-terms <list>", "Comma-separated search terms to use (override LLM auto-discovery)").option("--login-email <email>", "Login email (also PARITY_LOGIN_EMAIL env var)").option("--login-password <pwd>", "Login password (also PARITY_LOGIN_PASSWORD env var)").option("--output <dir>", "Output directory", "./parity-output").option("--open", "Open the HTML report after the run completes", false).option("--json", "Emit one-line JSON instead of pretty text", false).option("--no-auto-selectors", "Disable LLM-based selector discovery (uses defaults instead)").option("--refresh-selectors", "Bypass selector cache and re-run discovery", false).option("--no-learn", "Don't write to learned-selectors.json (read-only mode)").option("--fail-on <severities>", "Comma-separated severities that cause exit 1 (default: critical,high)", "critical,high").option("--pt", "Tell the LLM to respond in Brazilian Portuguese. Issue #67.").action(async (opts) => {
60236
60430
  if (opts.pt) {
60237
60431
  const { setLlmLanguage: setLlmLanguage2 } = await Promise.resolve().then(() => (init_client(), exports_client));
60238
60432
  setLlmLanguage2("pt");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/parity",
3
- "version": "0.13.0",
3
+ "version": "0.15.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",