@decocms/parity 0.9.0 → 0.10.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 +33 -0
  2. package/dist/cli.js +179 -43
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [0.10.1](https://github.com/decocms/parity/compare/v0.10.0...v0.10.1) (2026-05-30)
9
+
10
+ ### Fixed
11
+
12
+ * **Adaptive `scrollFullPage` — actually reaches the bottom on long pages.** Previous fixed-step loop with a 10s outer race only covered ~15000px before bailing, so 25000-40000px e-commerce homepages were captured with half the content still as skeletons. New loop re-measures `scrollHeight` every tick, waits inline for in-view skeleton placeholders to clear (up to 1.5s per step), and exits once page height has been stable for 3 consecutive checks. On miess home: 15 steps, 8862px reached, **0 skeletons at screenshot time** (was 7+ before). Race timeout bumped 10s → 45s, `capturePage` visual-diff `timeoutMs` 45s → 90s to fit the new scroll budget.
13
+
14
+ * **Silent `ReferenceError: __name is not defined` inside `page.evaluate`.** Took two debug rounds to isolate — `tsx`/esbuild injects a `__name` helper to preserve `.name` on arrow-function declarations (`const foo = () => ...`), but that helper doesn't exist in the page's browser context. The function threw immediately and `.catch(() => undefined)` swallowed it, making it look like a legitimate timeout. Fix: inlined the helper Promises inside `scrollFullPage`'s `page.evaluate`, and replaced silent `.catch` with one that logs the actual error message.
15
+
16
+ * **`SKELETON_DOWNGRADE_PCT_DIFF_CEILING = 0.25`.** The skeleton-vs-loaded downgrade was masking structural failures: any diff where the LLM mentioned "skeleton"/"placeholder" was forced to `low`, even when prod had a half-empty page (34%+ pctDiff). Now the downgrade only fires when pctDiff is under 25% — above that, the imbalance is treated as a real regression, not timing noise.
17
+
18
+ ### Changed
19
+
20
+ * `waitForSkeletonsToResolve` budget reduced 10s → 5s. The new adaptive scroll does inter-step skeleton waits, so the global safety net is a last-resort for off-screen skeletons rather than the primary defense.
21
+
22
+ * New `pre-screenshot skeletons=N` diagnostic log (via `DEBUG_PARITY=1`) so anyone investigating "is the page actually ready when we capture it?" can answer in one run.
23
+
24
+ ## [0.10.0](https://github.com/decocms/parity/compare/v0.9.0...v0.10.0) (2026-05-29)
25
+
26
+ ### Added
27
+
28
+ * **skeleton-aware capture pipeline:** `capturePage` now waits up to 6s for skeleton/loader placeholders (`.skeleton`, `[aria-busy]`, `.animate-pulse`, `.shimmer*`, `react-loading-skeleton`, generic `[class*='skeleton' i]`) to resolve before the screenshot fires. Stops the visual-diff LLM from reporting phantom "missing-component" diffs because one side raced ahead. `DomSnapshot.skeletonCount` exposes residual skeletons to downstream consumers.
29
+ * **skeleton-vs-loaded downgrade post-process:** when prod and cand differ in skeleton count by ≥5, LLM-reported `missing-component`/`different-component`/`extra-component` diffs whose description mentions skeleton/placeholder/shimmer wording are downgraded to `low` with an explanatory `[downgraded: skeleton-vs-loaded — ...]` suffix. Mirrors the carousel safety net from issue #22.
30
+
31
+ ### Fixed
32
+
33
+ * **scrollFullPage timing on heavy storefronts:** scroll step delay raised from 220ms → 400ms (gives shelf APIs time to dispatch before viewport moves past), bottom dwell from 400ms → 1500ms (footer + bottom carousels get to finish), plus a new 700ms settle after returning to the top (covers lazy frameworks that re-skeletonize on IntersectionObserver "leave"). This was the root cause of prod screenshots capturing 30-60% skeleton placeholders on Miess `/`, `/super-live`, `/intt-day`.
34
+ * **post-scroll networkidle wait:** added a 3s `networkidle` race after `scrollFullPage` in `capturePage`, plus 800ms (was 600ms) settle. Catches the lazy-fetch wave kicked off during the scroll-through before the screenshot fires.
35
+ * **visual-diff `settleMs` for the dedicated capture pass:** bumped from 1.8s → 4s and outer `timeoutMs` from 30s → 45s. These screenshots are the source of truth for the LLM verdict — flaky captures translate directly to flaky verdicts.
36
+
37
+ ### Changed
38
+
39
+ * **`LLM_PROMPT_VERSION` → `v3-skeleton`:** prompt now includes an explicit "skeleton/loading is timing, not regression" rule plus the prod/cand skeleton-count imbalance context. Bump invalidates all v2-keyed cache entries so prior verdicts get re-judged under the new heuristic.
40
+
8
41
  ## [0.9.0](https://github.com/decocms/parity/compare/v0.8.1...v0.9.0) (2026-05-29)
9
42
 
10
43
  ### Added
package/dist/cli.js CHANGED
@@ -884,7 +884,28 @@ function snapshotDom(html) {
884
884
  if (v)
885
885
  decoSectionsRendered.push(v);
886
886
  });
887
- return { counts, meta, imageStats, decoSectionsRendered: [...new Set(decoSectionsRendered)] };
887
+ let skeletonCount = 0;
888
+ for (const sel of [
889
+ "[aria-busy='true']",
890
+ "[data-skeleton]",
891
+ "[data-loading='true']",
892
+ ".skeleton",
893
+ "[class*='skeleton' i]",
894
+ "[class*='Skeleton']",
895
+ "[class*='shimmer' i]",
896
+ ".animate-pulse",
897
+ ".placeholder-shimmer",
898
+ ".react-loading-skeleton"
899
+ ]) {
900
+ skeletonCount += $(sel).length;
901
+ }
902
+ return {
903
+ counts,
904
+ meta,
905
+ imageStats,
906
+ decoSectionsRendered: [...new Set(decoSectionsRendered)],
907
+ skeletonCount
908
+ };
888
909
  }
889
910
  function diffDom(prod, cand, opts = {}) {
890
911
  const tol = opts.countTolerance ?? 2;
@@ -1905,28 +1926,65 @@ function parseIntOrNull(v) {
1905
1926
  const n = Number.parseInt(v, 10);
1906
1927
  return Number.isFinite(n) ? n : null;
1907
1928
  }
1908
- async function scrollFullPage(page) {
1909
- await page.evaluate(async () => {
1910
- await new Promise((resolve) => {
1911
- const step = Math.max(window.innerHeight * 0.8, 600);
1912
- let y = 0;
1913
- const max = document.documentElement.scrollHeight;
1914
- const tick = () => {
1915
- window.scrollTo(0, y);
1916
- y += step;
1917
- if (y < max) {
1918
- setTimeout(tick, 220);
1929
+ var SKELETON_SELECTOR = "[aria-busy='true'],[data-skeleton],[data-loading='true'],.skeleton,[class*='skeleton' i],[class*='Skeleton'],[class*='shimmer' i],.animate-pulse,.placeholder-shimmer,.react-loading-skeleton";
1930
+ async function waitForSkeletonsToResolve(page, maxMs = 1e4) {
1931
+ const start = Date.now();
1932
+ while (Date.now() - start < maxMs) {
1933
+ const count = await page.evaluate((sel) => document.querySelectorAll(sel).length, SKELETON_SELECTOR).catch(() => 0);
1934
+ if (count === 0)
1935
+ return;
1936
+ await page.waitForTimeout(500).catch(() => {
1937
+ return;
1938
+ });
1939
+ }
1940
+ }
1941
+ async function scrollFullPage(page, budgetMs = 45000) {
1942
+ const start = Date.now();
1943
+ const result = await page.evaluate(async (budget) => {
1944
+ const SK = "[aria-busy='true'],[data-skeleton],[data-loading='true'],.skeleton,[class*='skeleton' i],[class*='Skeleton'],[class*='shimmer' i],.animate-pulse,.placeholder-shimmer,.react-loading-skeleton";
1945
+ const innerStart = Date.now();
1946
+ let y = 0;
1947
+ let prevHeight = 0;
1948
+ let stableTicks = 0;
1949
+ let steps = 0;
1950
+ const STABLE_THRESHOLD = 3;
1951
+ while (budget - (Date.now() - innerStart) > 2000) {
1952
+ window.scrollTo(0, y);
1953
+ steps++;
1954
+ await new Promise((r) => setTimeout(r, 400));
1955
+ const stepWaitStart = Date.now();
1956
+ while (document.querySelectorAll(SK).length > 0 && Date.now() - stepWaitStart < 1500 && budget - (Date.now() - innerStart) > 1000) {
1957
+ await new Promise((r) => setTimeout(r, 200));
1958
+ }
1959
+ const height = document.documentElement.scrollHeight;
1960
+ const viewport = window.innerHeight;
1961
+ if (y + viewport >= height - 50) {
1962
+ if (height === prevHeight) {
1963
+ stableTicks++;
1964
+ if (stableTicks >= STABLE_THRESHOLD)
1965
+ break;
1919
1966
  } else {
1920
- window.scrollTo(0, max);
1921
- setTimeout(() => {
1922
- window.scrollTo(0, 0);
1923
- resolve();
1924
- }, 400);
1967
+ stableTicks = 0;
1968
+ prevHeight = height;
1925
1969
  }
1926
- };
1927
- tick();
1928
- });
1929
- });
1970
+ await new Promise((r) => setTimeout(r, 500));
1971
+ } else {
1972
+ y = Math.min(y + Math.max(viewport * 0.8, 600), height);
1973
+ }
1974
+ }
1975
+ const finalHeight = document.documentElement.scrollHeight;
1976
+ window.scrollTo(0, finalHeight);
1977
+ await new Promise((r) => setTimeout(r, 1500));
1978
+ window.scrollTo(0, 0);
1979
+ await new Promise((r) => setTimeout(r, 700));
1980
+ return { steps, finalHeight, stableAtEnd: stableTicks >= STABLE_THRESHOLD };
1981
+ }, budgetMs);
1982
+ return {
1983
+ steps: result.steps,
1984
+ finalHeight: result.finalHeight,
1985
+ stableAtEnd: result.stableAtEnd,
1986
+ durationMs: Date.now() - start
1987
+ };
1930
1988
  }
1931
1989
  function isFromHttpCache(resp) {
1932
1990
  const h = resp.headers();
@@ -1995,15 +2053,27 @@ async function capturePage(page, opts) {
1995
2053
  dlog(opts.side, opts.viewport, ` capturePage: settle (cap=${Math.min(opts.settleMs ?? 2000, remaining())}ms)`);
1996
2054
  await page.waitForTimeout(Math.min(opts.settleMs ?? 2000, remaining()));
1997
2055
  if (opts.scrollToLoad !== false && remaining() > 3000) {
1998
- dlog(opts.side, opts.viewport, ` capturePage: scrollFullPage start (remaining=${remaining()}ms)`);
1999
- await Promise.race([
2000
- scrollFullPage(page).catch(() => {
2056
+ const scrollBudget = Math.min(45000, remaining());
2057
+ dlog(opts.side, opts.viewport, ` capturePage: scrollFullPage start (budget=${scrollBudget}ms, remaining=${remaining()}ms)`);
2058
+ const scrollResult = await Promise.race([
2059
+ scrollFullPage(page, scrollBudget).catch((err) => {
2060
+ dlog(opts.side, opts.viewport, ` capturePage: scrollFullPage threw: ${err.message}`);
2001
2061
  return;
2002
2062
  }),
2003
- new Promise((resolve) => setTimeout(resolve, Math.min(1e4, remaining())))
2063
+ new Promise((resolve) => setTimeout(() => resolve(undefined), scrollBudget + 2000))
2004
2064
  ]);
2005
- dlog(opts.side, opts.viewport, ` capturePage: scrollFullPage done (remaining=${remaining()}ms)`);
2006
- await page.waitForTimeout(Math.min(600, remaining()));
2065
+ if (scrollResult) {
2066
+ dlog(opts.side, opts.viewport, ` capturePage: scrollFullPage done steps=${scrollResult.steps} finalHeight=${scrollResult.finalHeight} stable=${scrollResult.stableAtEnd} duration=${scrollResult.durationMs}ms (remaining=${remaining()}ms)`);
2067
+ } else {
2068
+ dlog(opts.side, opts.viewport, ` capturePage: scrollFullPage timed out at outer race (remaining=${remaining()}ms)`);
2069
+ }
2070
+ if (remaining() > 2000) {
2071
+ dlog(opts.side, opts.viewport, ` capturePage: post-scroll networkidle (cap=${Math.min(3000, remaining())}ms)`);
2072
+ await page.waitForLoadState("networkidle", { timeout: Math.min(3000, remaining()) }).catch(() => {
2073
+ return;
2074
+ });
2075
+ }
2076
+ await page.waitForTimeout(Math.min(800, remaining()));
2007
2077
  }
2008
2078
  }
2009
2079
  } catch (err) {
@@ -2024,6 +2094,20 @@ async function capturePage(page, opts) {
2024
2094
  }),
2025
2095
  new Promise((resolve) => setTimeout(resolve, Math.min(3000, remaining())))
2026
2096
  ]);
2097
+ const skeletonBudget = Math.min(5000, remaining());
2098
+ if (skeletonBudget > 500) {
2099
+ dlog(opts.side, opts.viewport, ` capturePage: waitForSkeletons (cap=${skeletonBudget}ms)`);
2100
+ await Promise.race([
2101
+ waitForSkeletonsToResolve(page, skeletonBudget).catch(() => {
2102
+ return;
2103
+ }),
2104
+ new Promise((resolve) => setTimeout(resolve, skeletonBudget))
2105
+ ]);
2106
+ }
2107
+ try {
2108
+ const skeletonsAtCapture = await page.evaluate((sel) => document.querySelectorAll(sel).length, SKELETON_SELECTOR).catch(() => -1);
2109
+ dlog(opts.side, opts.viewport, ` capturePage: pre-screenshot skeletons=${skeletonsAtCapture}`);
2110
+ } catch {}
2027
2111
  dlog(opts.side, opts.viewport, ` capturePage: screenshot start (cap=${Math.min(15000, remaining())}ms)`);
2028
2112
  await Promise.race([
2029
2113
  page.screenshot({ path: opts.screenshotPath, fullPage: true, animations: "disabled" }).catch(() => {
@@ -5756,13 +5840,15 @@ import ora3 from "ora";
5756
5840
 
5757
5841
  // src/checks/lib/pairing.ts
5758
5842
  function pairCaptures(prod, cand) {
5843
+ const prodByKey = new Map;
5844
+ for (const p of prod)
5845
+ prodByKey.set(captureKey(p), p);
5759
5846
  const candByKey = new Map;
5760
5847
  for (const c of cand)
5761
5848
  candByKey.set(captureKey(c), c);
5762
5849
  const pairs = [];
5763
5850
  const orphansProd = [];
5764
- for (const p of prod) {
5765
- const key = captureKey(p);
5851
+ for (const [key, p] of prodByKey) {
5766
5852
  const c = candByKey.get(key);
5767
5853
  if (c) {
5768
5854
  pairs.push({ prod: p, cand: c, viewport: p.viewport, key });
@@ -6337,7 +6423,7 @@ async function callOpenRouterMessage(params) {
6337
6423
  import { readFileSync as readFileSync5 } from "node:fs";
6338
6424
  import { PNG as PNG2 } from "pngjs";
6339
6425
  var MAX_HEIGHT = 8000;
6340
- var LLM_PROMPT_VERSION = "v2-heatmap";
6426
+ var LLM_PROMPT_VERSION = "v3-skeleton";
6341
6427
  var REPORT_VISUAL_DIFFS_TOOL = {
6342
6428
  name: "report_visual_differences",
6343
6429
  description: "Report semantic visual differences between prod and cand screenshots.",
@@ -6427,6 +6513,19 @@ IGNORE COMO RUÍDO (não reporte):
6427
6513
  - Stock counters / preços com pequena variação dinâmica
6428
6514
  - Pixel-level rendering quirks
6429
6515
 
6516
+ REGRA DE SKELETON / LOADING (timing — não regressão):
6517
+ - Skeletons / shimmers / placeholders cinzas com padrão de "card vazio" indicam
6518
+ que o lado em questão ainda estava buscando dados quando a screenshot foi
6519
+ tirada. Se UM lado mostra skeletons e o OUTRO lado mostra o componente real
6520
+ carregado pra o MESMO slot (mesma região, mesmo tipo de componente), isso é
6521
+ timing-noise, NÃO regressão.
6522
+ - Nesses casos a severidade deve ser \`low\` no máximo. Nunca \`critical\` ou
6523
+ \`high\`. Indique no \`description\` algo como "skeleton-vs-loaded — pode ser
6524
+ timing" pra o leitor humano não perder tempo.
6525
+ - Exceção: se um lado mostra a região INTEIRA em skeleton infinito (sem
6526
+ componente nem placeholder) enquanto o outro tem o conteúdo completo, isso
6527
+ pode ser um bug real de fetch — aí severity \`medium\`.
6528
+
6430
6529
  REGRA DE MODAL/POPUP (importante — não confundir com rota-errada):
6431
6530
  - Modal/popup visível em SÓ um lado é ruído APENAS se o conteúdo de fundo dos
6432
6531
  dois lados é o mesmo (ex: cookie banner em prod, sem cookie banner em cand,
@@ -6469,6 +6568,13 @@ function buildContextBlock(input) {
6469
6568
  if (input.bothHaveCarousel) {
6470
6569
  lines.push("", "**Ambos os lados expõem um carousel/slider no DOM.** Diferenças no conteúdo do hero (banner diferente, texto diferente, imagem diferente) provavelmente são apenas slides diferentes do mesmo carousel capturados em momentos distintos. NÃO reporte essas diferenças como critical/high. Se o carousel inteiro sumir, ou o layout do hero quebrar, isso sim reporte normalmente.");
6471
6570
  }
6571
+ const prodSkel = input.prodSkeletonCount ?? 0;
6572
+ const candSkel = input.candSkeletonCount ?? 0;
6573
+ if (Math.abs(prodSkel - candSkel) >= 5) {
6574
+ const heavier = prodSkel > candSkel ? "prod" : "cand";
6575
+ const lighter = prodSkel > candSkel ? "cand" : "prod";
6576
+ lines.push("", `**Desbalanço de skeleton/loaders detectado no DOM**: ${heavier} ainda tem ${Math.max(prodSkel, candSkel)} elementos skeleton enquanto ${lighter} tem ${Math.min(prodSkel, candSkel)}. Isso indica que ${heavier} não terminou o data fetch quando a screenshot foi tirada. Diferenças onde ${heavier} mostra placeholder cinza e ${lighter} mostra o componente carregado são TIMING NOISE — reporte com severity \`low\` no máximo e mencione "skeleton-vs-loaded" no description.`);
6577
+ }
6472
6578
  if (input.prodSections && input.prodSections.length > 0) {
6473
6579
  lines.push("", `Total sections em prod: ${input.prodSections.length}; em cand: ${input.candSections?.length ?? 0}.`);
6474
6580
  }
@@ -6623,6 +6729,36 @@ function downgradeCarouselFramingDiffs(diffs, bothHaveCarousel) {
6623
6729
  };
6624
6730
  });
6625
6731
  }
6732
+ var SKELETON_DESCRIPTION_RE = /skeleton|placeholder|shimmer|loading|carregando|esqueleto|esquemático|cinza|gray box|gray card|empty card|empty placeholder|loader/i;
6733
+ var SKELETON_DOWNGRADE_PCT_DIFF_CEILING = 0.25;
6734
+ function downgradeSkeletonImbalanceDiffs(diffs, prodSkeletonCount, candSkeletonCount, pctDiff) {
6735
+ if (Math.abs(prodSkeletonCount - candSkeletonCount) < SKELETON_IMBALANCE_THRESHOLD) {
6736
+ return diffs;
6737
+ }
6738
+ if (pctDiff > SKELETON_DOWNGRADE_PCT_DIFF_CEILING) {
6739
+ return diffs;
6740
+ }
6741
+ const heavier = prodSkeletonCount > candSkeletonCount ? "prod" : "cand";
6742
+ return diffs.map((d) => {
6743
+ if (d.severity === "low")
6744
+ return d;
6745
+ const eligible = [
6746
+ "missing-component",
6747
+ "different-component",
6748
+ "extra-component"
6749
+ ];
6750
+ if (!eligible.includes(d.type))
6751
+ return d;
6752
+ if (!SKELETON_DESCRIPTION_RE.test(d.description))
6753
+ return d;
6754
+ return {
6755
+ ...d,
6756
+ severity: "low",
6757
+ description: `${d.description} [downgraded: skeleton-vs-loaded — ${heavier} ainda tinha placeholders carregando quando a screenshot foi tirada; provável timing noise]`
6758
+ };
6759
+ });
6760
+ }
6761
+ var SKELETON_IMBALANCE_THRESHOLD = 5;
6626
6762
  function decideVerdict(args) {
6627
6763
  const hasCritical = args.differences.some((d) => d.severity === "critical" || d.severity === "high");
6628
6764
  const hasAnyDiff = args.differences.length > 0 || args.sectionsOnlyInProd.length > 0;
@@ -6682,6 +6818,8 @@ async function visualRegressionKeyframes(ctx) {
6682
6818
  const prodSet = new Set(prodSections);
6683
6819
  const sectionsOnlyInProd = prodSections.filter((s) => !candSet.has(s));
6684
6820
  const sectionsOnlyInCand = candSections.filter((s) => !prodSet.has(s));
6821
+ const prodSkeletonCount = prodSnapshot?.skeletonCount ?? 0;
6822
+ const candSkeletonCount = candSnapshot?.skeletonCount ?? 0;
6685
6823
  const bothHaveCarousel = hasCarouselSection(prodSections) && hasCarouselSection(candSections);
6686
6824
  let hash2;
6687
6825
  let cacheHit;
@@ -6708,6 +6846,8 @@ async function visualRegressionKeyframes(ctx) {
6708
6846
  sectionsOnlyInProd,
6709
6847
  sectionsOnlyInCand,
6710
6848
  bothHaveCarousel,
6849
+ prodSkeletonCount,
6850
+ candSkeletonCount,
6711
6851
  hash: hash2,
6712
6852
  cacheHit,
6713
6853
  preflightIssues
@@ -6750,7 +6890,9 @@ async function visualRegressionKeyframes(ctx) {
6750
6890
  prodSections: p.prodSections,
6751
6891
  candSections: p.candSections,
6752
6892
  sectionsOnlyInProd: p.sectionsOnlyInProd,
6753
- bothHaveCarousel: p.bothHaveCarousel
6893
+ bothHaveCarousel: p.bothHaveCarousel,
6894
+ prodSkeletonCount: p.prodSkeletonCount,
6895
+ candSkeletonCount: p.candSkeletonCount
6754
6896
  });
6755
6897
  differences = diffs ?? [];
6756
6898
  } catch (err) {
@@ -6759,6 +6901,7 @@ async function visualRegressionKeyframes(ctx) {
6759
6901
  }
6760
6902
  if (differences.length > 0) {
6761
6903
  differences = downgradeCarouselFramingDiffs(differences, p.bothHaveCarousel);
6904
+ differences = downgradeSkeletonImbalanceDiffs(differences, p.prodSkeletonCount, p.candSkeletonCount, p.pctDiff);
6762
6905
  }
6763
6906
  const verdict = decideVerdict({
6764
6907
  llmError,
@@ -17032,18 +17175,10 @@ async function runCommand(rawOpts) {
17032
17175
  const visualSpinner = ora6(explicitPaths ? `Visual diff: ${explicitPaths.length} página(s) explícita(s) via --pages/--pages-file…` : "Descobrindo páginas pra visual diff…").start();
17033
17176
  try {
17034
17177
  const visualPaths = explicitPaths ?? (await discoverPagesFromSitemap(opts.prod, { sampleSize: visualPagesLimit })).all.map((p) => p.path);
17035
- const alreadyCapturedKeys = new Set;
17036
- for (const p of allPageCaptures) {
17037
- try {
17038
- alreadyCapturedKeys.add(`${new URL(p.url).pathname}::${p.viewport}::${p.side}`);
17039
- } catch {}
17040
- }
17041
17178
  const tasks = [];
17042
17179
  for (const viewport of viewports) {
17043
17180
  for (const path of visualPaths) {
17044
17181
  for (const side of ["prod", "cand"]) {
17045
- if (alreadyCapturedKeys.has(`${path}::${viewport}::${side}`))
17046
- continue;
17047
17182
  tasks.push({ path, viewport, side });
17048
17183
  }
17049
17184
  }
@@ -17068,8 +17203,8 @@ async function runCommand(rawOpts) {
17068
17203
  side: task.side,
17069
17204
  viewport: task.viewport,
17070
17205
  screenshotPath: screenshotPath2,
17071
- settleMs: 1800,
17072
- timeoutMs: 30000,
17206
+ settleMs: 4000,
17207
+ timeoutMs: 90000,
17073
17208
  scrollToLoad: true
17074
17209
  });
17075
17210
  allPageCaptures.push(cap);
@@ -17261,8 +17396,9 @@ async function preflightCheck(prodUrl, candUrl, viewport) {
17261
17396
  errors.push(`${label}: URL inválida (${url})`);
17262
17397
  return;
17263
17398
  }
17399
+ const PREFLIGHT_TIMEOUT_MS = 30000;
17264
17400
  const controller = new AbortController;
17265
- const t = setTimeout(() => controller.abort(), 1e4);
17401
+ const t = setTimeout(() => controller.abort(), PREFLIGHT_TIMEOUT_MS);
17266
17402
  try {
17267
17403
  const res = await fetch(url, {
17268
17404
  method: "GET",
@@ -17278,7 +17414,7 @@ async function preflightCheck(prodUrl, candUrl, viewport) {
17278
17414
  errors.push(`${label}: HTTP ${res.status} ${res.statusText} (${url})`);
17279
17415
  } catch (err) {
17280
17416
  const e = err;
17281
- const msg = e.name === "AbortError" ? "timeout (10s)" : e.message;
17417
+ const msg = e.name === "AbortError" ? `timeout (${PREFLIGHT_TIMEOUT_MS / 1000}s)` : e.message;
17282
17418
  errors.push(`${label}: ${msg} (${url})`);
17283
17419
  } finally {
17284
17420
  clearTimeout(t);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/parity",
3
- "version": "0.9.0",
3
+ "version": "0.10.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",