@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.
- package/CHANGELOG.md +33 -0
- package/dist/cli.js +179 -43
- 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
|
-
|
|
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
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
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
|
-
|
|
1921
|
-
|
|
1922
|
-
window.scrollTo(0, 0);
|
|
1923
|
-
resolve();
|
|
1924
|
-
}, 400);
|
|
1967
|
+
stableTicks = 0;
|
|
1968
|
+
prevHeight = height;
|
|
1925
1969
|
}
|
|
1926
|
-
|
|
1927
|
-
|
|
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
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
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
|
|
2063
|
+
new Promise((resolve) => setTimeout(() => resolve(undefined), scrollBudget + 2000))
|
|
2004
2064
|
]);
|
|
2005
|
-
|
|
2006
|
-
|
|
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
|
|
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 = "
|
|
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:
|
|
17072
|
-
timeoutMs:
|
|
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(),
|
|
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" ?
|
|
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.
|
|
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",
|