@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.
- package/CHANGELOG.md +16 -0
- package/dist/cli.js +71 -31
- 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
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
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
|
-
|
|
1954
|
-
|
|
1955
|
-
window.scrollTo(0, 0);
|
|
1956
|
-
setTimeout(resolve, 700);
|
|
1957
|
-
}, 1500);
|
|
1967
|
+
stableTicks = 0;
|
|
1968
|
+
prevHeight = height;
|
|
1958
1969
|
}
|
|
1959
|
-
|
|
1960
|
-
|
|
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
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
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
|
|
2063
|
+
new Promise((resolve) => setTimeout(() => resolve(undefined), scrollBudget + 2000))
|
|
2037
2064
|
]);
|
|
2038
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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(),
|
|
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" ?
|
|
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.
|
|
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",
|