@decocms/parity 0.9.0 → 0.10.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 +17 -0
  2. package/dist/cli.js +115 -19
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ 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.0](https://github.com/decocms/parity/compare/v0.9.0...v0.10.0) (2026-05-29)
9
+
10
+ ### Added
11
+
12
+ * **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.
13
+ * **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.
14
+
15
+ ### Fixed
16
+
17
+ * **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`.
18
+ * **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.
19
+ * **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.
20
+
21
+ ### Changed
22
+
23
+ * **`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.
24
+
8
25
  ## [0.9.0](https://github.com/decocms/parity/compare/v0.8.1...v0.9.0) (2026-05-29)
9
26
 
10
27
  ### 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,6 +1926,18 @@ function parseIntOrNull(v) {
1905
1926
  const n = Number.parseInt(v, 10);
1906
1927
  return Number.isFinite(n) ? n : null;
1907
1928
  }
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
+ }
1908
1941
  async function scrollFullPage(page) {
1909
1942
  await page.evaluate(async () => {
1910
1943
  await new Promise((resolve) => {
@@ -1915,13 +1948,13 @@ async function scrollFullPage(page) {
1915
1948
  window.scrollTo(0, y);
1916
1949
  y += step;
1917
1950
  if (y < max) {
1918
- setTimeout(tick, 220);
1951
+ setTimeout(tick, 400);
1919
1952
  } else {
1920
1953
  window.scrollTo(0, max);
1921
1954
  setTimeout(() => {
1922
1955
  window.scrollTo(0, 0);
1923
- resolve();
1924
- }, 400);
1956
+ setTimeout(resolve, 700);
1957
+ }, 1500);
1925
1958
  }
1926
1959
  };
1927
1960
  tick();
@@ -2003,7 +2036,13 @@ async function capturePage(page, opts) {
2003
2036
  new Promise((resolve) => setTimeout(resolve, Math.min(1e4, remaining())))
2004
2037
  ]);
2005
2038
  dlog(opts.side, opts.viewport, ` capturePage: scrollFullPage done (remaining=${remaining()}ms)`);
2006
- await page.waitForTimeout(Math.min(600, remaining()));
2039
+ if (remaining() > 2000) {
2040
+ dlog(opts.side, opts.viewport, ` capturePage: post-scroll networkidle (cap=${Math.min(3000, remaining())}ms)`);
2041
+ await page.waitForLoadState("networkidle", { timeout: Math.min(3000, remaining()) }).catch(() => {
2042
+ return;
2043
+ });
2044
+ }
2045
+ await page.waitForTimeout(Math.min(800, remaining()));
2007
2046
  }
2008
2047
  }
2009
2048
  } catch (err) {
@@ -2024,6 +2063,16 @@ async function capturePage(page, opts) {
2024
2063
  }),
2025
2064
  new Promise((resolve) => setTimeout(resolve, Math.min(3000, remaining())))
2026
2065
  ]);
2066
+ const skeletonBudget = Math.min(1e4, remaining());
2067
+ if (skeletonBudget > 500) {
2068
+ dlog(opts.side, opts.viewport, ` capturePage: waitForSkeletons (cap=${skeletonBudget}ms)`);
2069
+ await Promise.race([
2070
+ waitForSkeletonsToResolve(page, skeletonBudget).catch(() => {
2071
+ return;
2072
+ }),
2073
+ new Promise((resolve) => setTimeout(resolve, skeletonBudget))
2074
+ ]);
2075
+ }
2027
2076
  dlog(opts.side, opts.viewport, ` capturePage: screenshot start (cap=${Math.min(15000, remaining())}ms)`);
2028
2077
  await Promise.race([
2029
2078
  page.screenshot({ path: opts.screenshotPath, fullPage: true, animations: "disabled" }).catch(() => {
@@ -5756,13 +5805,15 @@ import ora3 from "ora";
5756
5805
 
5757
5806
  // src/checks/lib/pairing.ts
5758
5807
  function pairCaptures(prod, cand) {
5808
+ const prodByKey = new Map;
5809
+ for (const p of prod)
5810
+ prodByKey.set(captureKey(p), p);
5759
5811
  const candByKey = new Map;
5760
5812
  for (const c of cand)
5761
5813
  candByKey.set(captureKey(c), c);
5762
5814
  const pairs = [];
5763
5815
  const orphansProd = [];
5764
- for (const p of prod) {
5765
- const key = captureKey(p);
5816
+ for (const [key, p] of prodByKey) {
5766
5817
  const c = candByKey.get(key);
5767
5818
  if (c) {
5768
5819
  pairs.push({ prod: p, cand: c, viewport: p.viewport, key });
@@ -6337,7 +6388,7 @@ async function callOpenRouterMessage(params) {
6337
6388
  import { readFileSync as readFileSync5 } from "node:fs";
6338
6389
  import { PNG as PNG2 } from "pngjs";
6339
6390
  var MAX_HEIGHT = 8000;
6340
- var LLM_PROMPT_VERSION = "v2-heatmap";
6391
+ var LLM_PROMPT_VERSION = "v3-skeleton";
6341
6392
  var REPORT_VISUAL_DIFFS_TOOL = {
6342
6393
  name: "report_visual_differences",
6343
6394
  description: "Report semantic visual differences between prod and cand screenshots.",
@@ -6427,6 +6478,19 @@ IGNORE COMO RUÍDO (não reporte):
6427
6478
  - Stock counters / preços com pequena variação dinâmica
6428
6479
  - Pixel-level rendering quirks
6429
6480
 
6481
+ REGRA DE SKELETON / LOADING (timing — não regressão):
6482
+ - Skeletons / shimmers / placeholders cinzas com padrão de "card vazio" indicam
6483
+ que o lado em questão ainda estava buscando dados quando a screenshot foi
6484
+ tirada. Se UM lado mostra skeletons e o OUTRO lado mostra o componente real
6485
+ carregado pra o MESMO slot (mesma região, mesmo tipo de componente), isso é
6486
+ timing-noise, NÃO regressão.
6487
+ - Nesses casos a severidade deve ser \`low\` no máximo. Nunca \`critical\` ou
6488
+ \`high\`. Indique no \`description\` algo como "skeleton-vs-loaded — pode ser
6489
+ timing" pra o leitor humano não perder tempo.
6490
+ - Exceção: se um lado mostra a região INTEIRA em skeleton infinito (sem
6491
+ componente nem placeholder) enquanto o outro tem o conteúdo completo, isso
6492
+ pode ser um bug real de fetch — aí severity \`medium\`.
6493
+
6430
6494
  REGRA DE MODAL/POPUP (importante — não confundir com rota-errada):
6431
6495
  - Modal/popup visível em SÓ um lado é ruído APENAS se o conteúdo de fundo dos
6432
6496
  dois lados é o mesmo (ex: cookie banner em prod, sem cookie banner em cand,
@@ -6469,6 +6533,13 @@ function buildContextBlock(input) {
6469
6533
  if (input.bothHaveCarousel) {
6470
6534
  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
6535
  }
6536
+ const prodSkel = input.prodSkeletonCount ?? 0;
6537
+ const candSkel = input.candSkeletonCount ?? 0;
6538
+ if (Math.abs(prodSkel - candSkel) >= 5) {
6539
+ const heavier = prodSkel > candSkel ? "prod" : "cand";
6540
+ const lighter = prodSkel > candSkel ? "cand" : "prod";
6541
+ 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.`);
6542
+ }
6472
6543
  if (input.prodSections && input.prodSections.length > 0) {
6473
6544
  lines.push("", `Total sections em prod: ${input.prodSections.length}; em cand: ${input.candSections?.length ?? 0}.`);
6474
6545
  }
@@ -6623,6 +6694,32 @@ function downgradeCarouselFramingDiffs(diffs, bothHaveCarousel) {
6623
6694
  };
6624
6695
  });
6625
6696
  }
6697
+ var SKELETON_DESCRIPTION_RE = /skeleton|placeholder|shimmer|loading|carregando|esqueleto|esquemático|cinza|gray box|gray card|empty card|empty placeholder|loader/i;
6698
+ function downgradeSkeletonImbalanceDiffs(diffs, prodSkeletonCount, candSkeletonCount) {
6699
+ if (Math.abs(prodSkeletonCount - candSkeletonCount) < SKELETON_IMBALANCE_THRESHOLD) {
6700
+ return diffs;
6701
+ }
6702
+ const heavier = prodSkeletonCount > candSkeletonCount ? "prod" : "cand";
6703
+ return diffs.map((d) => {
6704
+ if (d.severity === "low")
6705
+ return d;
6706
+ const eligible = [
6707
+ "missing-component",
6708
+ "different-component",
6709
+ "extra-component"
6710
+ ];
6711
+ if (!eligible.includes(d.type))
6712
+ return d;
6713
+ if (!SKELETON_DESCRIPTION_RE.test(d.description))
6714
+ return d;
6715
+ return {
6716
+ ...d,
6717
+ severity: "low",
6718
+ description: `${d.description} [downgraded: skeleton-vs-loaded — ${heavier} ainda tinha placeholders carregando quando a screenshot foi tirada; provável timing noise]`
6719
+ };
6720
+ });
6721
+ }
6722
+ var SKELETON_IMBALANCE_THRESHOLD = 5;
6626
6723
  function decideVerdict(args) {
6627
6724
  const hasCritical = args.differences.some((d) => d.severity === "critical" || d.severity === "high");
6628
6725
  const hasAnyDiff = args.differences.length > 0 || args.sectionsOnlyInProd.length > 0;
@@ -6682,6 +6779,8 @@ async function visualRegressionKeyframes(ctx) {
6682
6779
  const prodSet = new Set(prodSections);
6683
6780
  const sectionsOnlyInProd = prodSections.filter((s) => !candSet.has(s));
6684
6781
  const sectionsOnlyInCand = candSections.filter((s) => !prodSet.has(s));
6782
+ const prodSkeletonCount = prodSnapshot?.skeletonCount ?? 0;
6783
+ const candSkeletonCount = candSnapshot?.skeletonCount ?? 0;
6685
6784
  const bothHaveCarousel = hasCarouselSection(prodSections) && hasCarouselSection(candSections);
6686
6785
  let hash2;
6687
6786
  let cacheHit;
@@ -6708,6 +6807,8 @@ async function visualRegressionKeyframes(ctx) {
6708
6807
  sectionsOnlyInProd,
6709
6808
  sectionsOnlyInCand,
6710
6809
  bothHaveCarousel,
6810
+ prodSkeletonCount,
6811
+ candSkeletonCount,
6711
6812
  hash: hash2,
6712
6813
  cacheHit,
6713
6814
  preflightIssues
@@ -6750,7 +6851,9 @@ async function visualRegressionKeyframes(ctx) {
6750
6851
  prodSections: p.prodSections,
6751
6852
  candSections: p.candSections,
6752
6853
  sectionsOnlyInProd: p.sectionsOnlyInProd,
6753
- bothHaveCarousel: p.bothHaveCarousel
6854
+ bothHaveCarousel: p.bothHaveCarousel,
6855
+ prodSkeletonCount: p.prodSkeletonCount,
6856
+ candSkeletonCount: p.candSkeletonCount
6754
6857
  });
6755
6858
  differences = diffs ?? [];
6756
6859
  } catch (err) {
@@ -6759,6 +6862,7 @@ async function visualRegressionKeyframes(ctx) {
6759
6862
  }
6760
6863
  if (differences.length > 0) {
6761
6864
  differences = downgradeCarouselFramingDiffs(differences, p.bothHaveCarousel);
6865
+ differences = downgradeSkeletonImbalanceDiffs(differences, p.prodSkeletonCount, p.candSkeletonCount);
6762
6866
  }
6763
6867
  const verdict = decideVerdict({
6764
6868
  llmError,
@@ -17032,18 +17136,10 @@ async function runCommand(rawOpts) {
17032
17136
  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
17137
  try {
17034
17138
  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
17139
  const tasks = [];
17042
17140
  for (const viewport of viewports) {
17043
17141
  for (const path of visualPaths) {
17044
17142
  for (const side of ["prod", "cand"]) {
17045
- if (alreadyCapturedKeys.has(`${path}::${viewport}::${side}`))
17046
- continue;
17047
17143
  tasks.push({ path, viewport, side });
17048
17144
  }
17049
17145
  }
@@ -17068,8 +17164,8 @@ async function runCommand(rawOpts) {
17068
17164
  side: task.side,
17069
17165
  viewport: task.viewport,
17070
17166
  screenshotPath: screenshotPath2,
17071
- settleMs: 1800,
17072
- timeoutMs: 30000,
17167
+ settleMs: 4000,
17168
+ timeoutMs: 45000,
17073
17169
  scrollToLoad: true
17074
17170
  });
17075
17171
  allPageCaptures.push(cap);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/parity",
3
- "version": "0.9.0",
3
+ "version": "0.10.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",