@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.
- package/CHANGELOG.md +17 -0
- package/dist/cli.js +115 -19
- 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
|
-
|
|
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,
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
|
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 = "
|
|
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:
|
|
17072
|
-
timeoutMs:
|
|
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.
|
|
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",
|