@decocms/parity 0.10.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 +16 -0
  2. package/dist/cli.js +71 -31
  3. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ 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
+
8
24
  ## [0.10.0](https://github.com/decocms/parity/compare/v0.9.0...v0.10.0) (2026-05-29)
9
25
 
10
26
  ### Added
package/dist/cli.js CHANGED
@@ -1938,28 +1938,53 @@ async function waitForSkeletonsToResolve(page, maxMs = 1e4) {
1938
1938
  });
1939
1939
  }
1940
1940
  }
1941
- async function scrollFullPage(page) {
1942
- await page.evaluate(async () => {
1943
- await new Promise((resolve) => {
1944
- const step = Math.max(window.innerHeight * 0.8, 600);
1945
- let y = 0;
1946
- const max = document.documentElement.scrollHeight;
1947
- const tick = () => {
1948
- window.scrollTo(0, y);
1949
- y += step;
1950
- if (y < max) {
1951
- setTimeout(tick, 400);
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;
1952
1966
  } else {
1953
- window.scrollTo(0, max);
1954
- setTimeout(() => {
1955
- window.scrollTo(0, 0);
1956
- setTimeout(resolve, 700);
1957
- }, 1500);
1967
+ stableTicks = 0;
1968
+ prevHeight = height;
1958
1969
  }
1959
- };
1960
- tick();
1961
- });
1962
- });
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
+ };
1963
1988
  }
1964
1989
  function isFromHttpCache(resp) {
1965
1990
  const h = resp.headers();
@@ -2028,14 +2053,20 @@ async function capturePage(page, opts) {
2028
2053
  dlog(opts.side, opts.viewport, ` capturePage: settle (cap=${Math.min(opts.settleMs ?? 2000, remaining())}ms)`);
2029
2054
  await page.waitForTimeout(Math.min(opts.settleMs ?? 2000, remaining()));
2030
2055
  if (opts.scrollToLoad !== false && remaining() > 3000) {
2031
- dlog(opts.side, opts.viewport, ` capturePage: scrollFullPage start (remaining=${remaining()}ms)`);
2032
- await Promise.race([
2033
- 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}`);
2034
2061
  return;
2035
2062
  }),
2036
- new Promise((resolve) => setTimeout(resolve, Math.min(1e4, remaining())))
2063
+ new Promise((resolve) => setTimeout(() => resolve(undefined), scrollBudget + 2000))
2037
2064
  ]);
2038
- dlog(opts.side, opts.viewport, ` capturePage: scrollFullPage done (remaining=${remaining()}ms)`);
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
+ }
2039
2070
  if (remaining() > 2000) {
2040
2071
  dlog(opts.side, opts.viewport, ` capturePage: post-scroll networkidle (cap=${Math.min(3000, remaining())}ms)`);
2041
2072
  await page.waitForLoadState("networkidle", { timeout: Math.min(3000, remaining()) }).catch(() => {
@@ -2063,7 +2094,7 @@ async function capturePage(page, opts) {
2063
2094
  }),
2064
2095
  new Promise((resolve) => setTimeout(resolve, Math.min(3000, remaining())))
2065
2096
  ]);
2066
- const skeletonBudget = Math.min(1e4, remaining());
2097
+ const skeletonBudget = Math.min(5000, remaining());
2067
2098
  if (skeletonBudget > 500) {
2068
2099
  dlog(opts.side, opts.viewport, ` capturePage: waitForSkeletons (cap=${skeletonBudget}ms)`);
2069
2100
  await Promise.race([
@@ -2073,6 +2104,10 @@ async function capturePage(page, opts) {
2073
2104
  new Promise((resolve) => setTimeout(resolve, skeletonBudget))
2074
2105
  ]);
2075
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 {}
2076
2111
  dlog(opts.side, opts.viewport, ` capturePage: screenshot start (cap=${Math.min(15000, remaining())}ms)`);
2077
2112
  await Promise.race([
2078
2113
  page.screenshot({ path: opts.screenshotPath, fullPage: true, animations: "disabled" }).catch(() => {
@@ -6695,10 +6730,14 @@ function downgradeCarouselFramingDiffs(diffs, bothHaveCarousel) {
6695
6730
  });
6696
6731
  }
6697
6732
  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) {
6733
+ var SKELETON_DOWNGRADE_PCT_DIFF_CEILING = 0.25;
6734
+ function downgradeSkeletonImbalanceDiffs(diffs, prodSkeletonCount, candSkeletonCount, pctDiff) {
6699
6735
  if (Math.abs(prodSkeletonCount - candSkeletonCount) < SKELETON_IMBALANCE_THRESHOLD) {
6700
6736
  return diffs;
6701
6737
  }
6738
+ if (pctDiff > SKELETON_DOWNGRADE_PCT_DIFF_CEILING) {
6739
+ return diffs;
6740
+ }
6702
6741
  const heavier = prodSkeletonCount > candSkeletonCount ? "prod" : "cand";
6703
6742
  return diffs.map((d) => {
6704
6743
  if (d.severity === "low")
@@ -6862,7 +6901,7 @@ async function visualRegressionKeyframes(ctx) {
6862
6901
  }
6863
6902
  if (differences.length > 0) {
6864
6903
  differences = downgradeCarouselFramingDiffs(differences, p.bothHaveCarousel);
6865
- differences = downgradeSkeletonImbalanceDiffs(differences, p.prodSkeletonCount, p.candSkeletonCount);
6904
+ differences = downgradeSkeletonImbalanceDiffs(differences, p.prodSkeletonCount, p.candSkeletonCount, p.pctDiff);
6866
6905
  }
6867
6906
  const verdict = decideVerdict({
6868
6907
  llmError,
@@ -17165,7 +17204,7 @@ async function runCommand(rawOpts) {
17165
17204
  viewport: task.viewport,
17166
17205
  screenshotPath: screenshotPath2,
17167
17206
  settleMs: 4000,
17168
- timeoutMs: 45000,
17207
+ timeoutMs: 90000,
17169
17208
  scrollToLoad: true
17170
17209
  });
17171
17210
  allPageCaptures.push(cap);
@@ -17357,8 +17396,9 @@ async function preflightCheck(prodUrl, candUrl, viewport) {
17357
17396
  errors.push(`${label}: URL inválida (${url})`);
17358
17397
  return;
17359
17398
  }
17399
+ const PREFLIGHT_TIMEOUT_MS = 30000;
17360
17400
  const controller = new AbortController;
17361
- const t = setTimeout(() => controller.abort(), 1e4);
17401
+ const t = setTimeout(() => controller.abort(), PREFLIGHT_TIMEOUT_MS);
17362
17402
  try {
17363
17403
  const res = await fetch(url, {
17364
17404
  method: "GET",
@@ -17374,7 +17414,7 @@ async function preflightCheck(prodUrl, candUrl, viewport) {
17374
17414
  errors.push(`${label}: HTTP ${res.status} ${res.statusText} (${url})`);
17375
17415
  } catch (err) {
17376
17416
  const e = err;
17377
- const msg = e.name === "AbortError" ? "timeout (10s)" : e.message;
17417
+ const msg = e.name === "AbortError" ? `timeout (${PREFLIGHT_TIMEOUT_MS / 1000}s)` : e.message;
17378
17418
  errors.push(`${label}: ${msg} (${url})`);
17379
17419
  } finally {
17380
17420
  clearTimeout(t);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/parity",
3
- "version": "0.10.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",