@percy/dom 1.31.14-beta.3 → 1.31.14-beta.5
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/dist/bundle.js +686 -2
- package/package.json +2 -2
package/dist/bundle.js
CHANGED
|
@@ -398,7 +398,17 @@
|
|
|
398
398
|
if (!sheetA.cssRules || !sheetB.cssRules) return false;
|
|
399
399
|
const lenA = sheetA.cssRules.length;
|
|
400
400
|
const lenB = sheetB.cssRules.length;
|
|
401
|
-
|
|
401
|
+
|
|
402
|
+
// Only treat as mismatch when the live sheet has MORE rules than the
|
|
403
|
+
// clone — that signals rules were added via CSSOM (insertRule/adopted)
|
|
404
|
+
// and must be re-serialized. When lenA <= lenB, the clone already
|
|
405
|
+
// contains all of the live rules (often duplicated by clone-dom's
|
|
406
|
+
// <style> textContent handling); trust the clone's source text so that
|
|
407
|
+
// CSS shorthand semantics (e.g. `all: initial; border-radius: var(...)`)
|
|
408
|
+
// survive — `cssRule.cssText` expansion appends logical longhands like
|
|
409
|
+
// `border-end-end-radius: initial` AFTER the shorthand, which silently
|
|
410
|
+
// overrides it and breaks the cascade.
|
|
411
|
+
if (lenA > lenB) return false;
|
|
402
412
|
for (let i = 0; i < lenA; i++) {
|
|
403
413
|
const ruleA = sheetA.cssRules[i] && sheetA.cssRules[i].cssText;
|
|
404
414
|
const ruleB = sheetB.cssRules[i] && sheetB.cssRules[i].cssText;
|
|
@@ -1521,7 +1531,8 @@
|
|
|
1521
1531
|
window.resizeCount = 0;
|
|
1522
1532
|
}
|
|
1523
1533
|
|
|
1524
|
-
//
|
|
1534
|
+
// Synchronous DOM serializer. For readiness gating, call `PercyDOM.waitForReady(config)`
|
|
1535
|
+
// before this — see readiness.js.
|
|
1525
1536
|
function serializeDOM(options) {
|
|
1526
1537
|
let {
|
|
1527
1538
|
dom = document,
|
|
@@ -1661,10 +1672,683 @@
|
|
|
1661
1672
|
return allImgTags;
|
|
1662
1673
|
}
|
|
1663
1674
|
|
|
1675
|
+
/* eslint-disable no-undef */
|
|
1676
|
+
// Browser globals (performance, MutationObserver, document, window, getComputedStyle)
|
|
1677
|
+
// are available in the browser execution context where this code runs.
|
|
1678
|
+
|
|
1679
|
+
// Readiness check presets
|
|
1680
|
+
//
|
|
1681
|
+
// `js_idle_window_ms` is separate from `stability_window_ms` on purpose:
|
|
1682
|
+
// DOM stability and main-thread idleness measure different things. With
|
|
1683
|
+
// the `strict` preset we want a long DOM-stability window (1000ms) but
|
|
1684
|
+
// not necessarily 1000ms of no long tasks — that would cause unnecessary
|
|
1685
|
+
// timeouts on pages with normal JS activity. Both windows are
|
|
1686
|
+
// independently configurable but default to reasonable values per preset.
|
|
1687
|
+
const PRESETS = {
|
|
1688
|
+
balanced: {
|
|
1689
|
+
stability_window_ms: 300,
|
|
1690
|
+
js_idle_window_ms: 300,
|
|
1691
|
+
network_idle_window_ms: 200,
|
|
1692
|
+
timeout_ms: 10000,
|
|
1693
|
+
dom_stability: true,
|
|
1694
|
+
image_ready: true,
|
|
1695
|
+
font_ready: true,
|
|
1696
|
+
js_idle: true
|
|
1697
|
+
},
|
|
1698
|
+
strict: {
|
|
1699
|
+
stability_window_ms: 1000,
|
|
1700
|
+
js_idle_window_ms: 500,
|
|
1701
|
+
network_idle_window_ms: 500,
|
|
1702
|
+
timeout_ms: 30000,
|
|
1703
|
+
dom_stability: true,
|
|
1704
|
+
image_ready: true,
|
|
1705
|
+
font_ready: true,
|
|
1706
|
+
js_idle: true
|
|
1707
|
+
},
|
|
1708
|
+
fast: {
|
|
1709
|
+
stability_window_ms: 100,
|
|
1710
|
+
js_idle_window_ms: 100,
|
|
1711
|
+
network_idle_window_ms: 100,
|
|
1712
|
+
timeout_ms: 5000,
|
|
1713
|
+
dom_stability: true,
|
|
1714
|
+
image_ready: false,
|
|
1715
|
+
font_ready: true,
|
|
1716
|
+
js_idle: true
|
|
1717
|
+
}
|
|
1718
|
+
};
|
|
1719
|
+
const LAYOUT_ATTRIBUTES = new Set(['class', 'width', 'height', 'display', 'visibility', 'position', 'src']);
|
|
1720
|
+
const LAYOUT_STYLE_PROPS = /^(width|height|top|left|right|bottom|margin|padding|display|position|visibility|flex|grid|min-|max-|inset|gap|order|float|clear|overflow|z-index|columns)/;
|
|
1721
|
+
|
|
1722
|
+
// Exported for direct unit testing — logic is deterministic and does not
|
|
1723
|
+
// depend on browser timing, so it should not be covered only indirectly
|
|
1724
|
+
// through MutationObserver-driven integration tests.
|
|
1725
|
+
function isLayoutMutation(mutation) {
|
|
1726
|
+
if (mutation.type === 'childList') return true;
|
|
1727
|
+
if (mutation.type === 'attributes') {
|
|
1728
|
+
let attr = mutation.attributeName;
|
|
1729
|
+
if (attr.startsWith('data-') || attr.startsWith('aria-')) return false;
|
|
1730
|
+
if (attr === 'style') {
|
|
1731
|
+
let oldStyle = mutation.oldValue || '';
|
|
1732
|
+
let newStyle = mutation.target.getAttribute('style') || '';
|
|
1733
|
+
return hasLayoutStyleChange(oldStyle, newStyle);
|
|
1734
|
+
}
|
|
1735
|
+
// href is only layout-affecting on <link> elements (stylesheets).
|
|
1736
|
+
// On <a> tags changing href is a no-op for layout.
|
|
1737
|
+
if (attr === 'href') return mutation.target.tagName === 'LINK';
|
|
1738
|
+
if (LAYOUT_ATTRIBUTES.has(attr)) return true;
|
|
1739
|
+
}
|
|
1740
|
+
return false;
|
|
1741
|
+
}
|
|
1742
|
+
function hasLayoutStyleChange(oldStyle, newStyle) {
|
|
1743
|
+
if (oldStyle === newStyle) return false;
|
|
1744
|
+
let oldProps = parseStyleProps(oldStyle);
|
|
1745
|
+
let newProps = parseStyleProps(newStyle);
|
|
1746
|
+
let allKeys = new Set([...Object.keys(oldProps), ...Object.keys(newProps)]);
|
|
1747
|
+
for (let key of allKeys) {
|
|
1748
|
+
if (LAYOUT_STYLE_PROPS.test(key) && oldProps[key] !== newProps[key]) return true;
|
|
1749
|
+
}
|
|
1750
|
+
return false;
|
|
1751
|
+
}
|
|
1752
|
+
function parseStyleProps(styleStr) {
|
|
1753
|
+
let props = {};
|
|
1754
|
+
if (!styleStr) return props;
|
|
1755
|
+
for (let part of styleStr.split(';')) {
|
|
1756
|
+
let i = part.indexOf(':');
|
|
1757
|
+
if (i > 0) {
|
|
1758
|
+
let key = part.slice(0, i).trim().toLowerCase();
|
|
1759
|
+
if (key) props[key] = part.slice(i + 1).trim();
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
return props;
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
// Resolve a single ready/notPresent selector to a DOM Element. Accepts:
|
|
1766
|
+
// - CSS string: '.app-loaded'
|
|
1767
|
+
// - XPath string: '//div[@id="root"]' (sniffed by leading /, //, ./, (/, (./)
|
|
1768
|
+
// - Object form (explicit): { css: '.foo' } | { xpath: '//bar' }
|
|
1769
|
+
// Returns the matched Element, or null when no element matches, the
|
|
1770
|
+
// selector is malformed, or it resolves to a non-Element node.
|
|
1771
|
+
//
|
|
1772
|
+
// Exported for direct unit testing.
|
|
1773
|
+
const XPATH_SNIFF = /^\(?\.?\//;
|
|
1774
|
+
function resolveSelector(selector) {
|
|
1775
|
+
if (!selector) return null;
|
|
1776
|
+
let xpath = null;
|
|
1777
|
+
let css = null;
|
|
1778
|
+
if (typeof selector === 'object') {
|
|
1779
|
+
if (selector.xpath) xpath = selector.xpath;else if (selector.css) css = selector.css;else return null;
|
|
1780
|
+
} else if (typeof selector === 'string') {
|
|
1781
|
+
if (XPATH_SNIFF.test(selector)) xpath = selector;else css = selector;
|
|
1782
|
+
} else {
|
|
1783
|
+
return null;
|
|
1784
|
+
}
|
|
1785
|
+
try {
|
|
1786
|
+
let el = xpath ? document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue : document.querySelector(css);
|
|
1787
|
+
return el instanceof Element ? el : null;
|
|
1788
|
+
} catch (e) {
|
|
1789
|
+
// Malformed XPath or invalid CSS — treat as no-match so the selector
|
|
1790
|
+
// gate keeps polling rather than blowing up the entire readiness gate.
|
|
1791
|
+
return null;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// Subscribe to PerformanceObserver entries of a given type. Returns the
|
|
1796
|
+
// observer (for the caller to disconnect) or null when PerformanceObserver
|
|
1797
|
+
// (or the requested entry type) is unavailable, so callers can fall back.
|
|
1798
|
+
//
|
|
1799
|
+
// Used by checkNetworkIdle (`resource`) and checkJSIdle (`longtask`) to
|
|
1800
|
+
// avoid duplicating the try/observe/disconnect boilerplate.
|
|
1801
|
+
function observePerformance(type, onEntries) {
|
|
1802
|
+
try {
|
|
1803
|
+
let observer = new PerformanceObserver(list => onEntries(list.getEntries()));
|
|
1804
|
+
observer.observe({
|
|
1805
|
+
type,
|
|
1806
|
+
buffered: false
|
|
1807
|
+
});
|
|
1808
|
+
return observer;
|
|
1809
|
+
} catch (e) /* istanbul ignore next: PerformanceObserver is available in Chrome/Firefox; catch is for old browsers */{
|
|
1810
|
+
return null;
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// --- Individual Checks ---
|
|
1815
|
+
// Each check accepts an `aborted` object ({ value: boolean }) so the orchestrator
|
|
1816
|
+
// can signal cancellation on timeout. Checks must clean up timers/observers on abort.
|
|
1817
|
+
|
|
1818
|
+
function checkDOMStability(stabilityWindowMs, aborted) {
|
|
1819
|
+
return new Promise(resolve => {
|
|
1820
|
+
let startTime = performance.now();
|
|
1821
|
+
let timer = null;
|
|
1822
|
+
let mutationCount = 0;
|
|
1823
|
+
let lastMutationType = null;
|
|
1824
|
+
let observer = new MutationObserver(mutations => {
|
|
1825
|
+
/* istanbul ignore next: abort disconnects the observer synchronously, defensive dead code in tests */
|
|
1826
|
+
if (aborted.value) return;
|
|
1827
|
+
let hasLayout = false;
|
|
1828
|
+
for (let m of mutations) {
|
|
1829
|
+
if (isLayoutMutation(m)) {
|
|
1830
|
+
hasLayout = true;
|
|
1831
|
+
mutationCount++;
|
|
1832
|
+
lastMutationType = m.type;
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
/* istanbul ignore next: timer is always set before observer fires */
|
|
1836
|
+
if (hasLayout) {
|
|
1837
|
+
if (timer) clearTimeout(timer);
|
|
1838
|
+
timer = setTimeout(settle, stabilityWindowMs);
|
|
1839
|
+
}
|
|
1840
|
+
});
|
|
1841
|
+
function settle() {
|
|
1842
|
+
observer.disconnect();
|
|
1843
|
+
resolve({
|
|
1844
|
+
passed: true,
|
|
1845
|
+
duration_ms: Math.round(performance.now() - startTime),
|
|
1846
|
+
mutations_observed: mutationCount,
|
|
1847
|
+
last_mutation_type: lastMutationType
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
observer.observe(document.documentElement, {
|
|
1851
|
+
childList: true,
|
|
1852
|
+
attributes: true,
|
|
1853
|
+
attributeOldValue: true,
|
|
1854
|
+
subtree: true,
|
|
1855
|
+
attributeFilter: [...LAYOUT_ATTRIBUTES, 'style', 'href']
|
|
1856
|
+
});
|
|
1857
|
+
timer = setTimeout(settle, stabilityWindowMs);
|
|
1858
|
+
|
|
1859
|
+
// Cleanup on abort
|
|
1860
|
+
aborted.onAbort(() => {
|
|
1861
|
+
/* istanbul ignore next: timer is always set at line 124 before abort can fire */
|
|
1862
|
+
if (timer) clearTimeout(timer);
|
|
1863
|
+
observer.disconnect();
|
|
1864
|
+
});
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
function checkNetworkIdle(networkIdleWindowMs, aborted) {
|
|
1868
|
+
return new Promise(resolve => {
|
|
1869
|
+
let startTime = performance.now();
|
|
1870
|
+
let timer = null;
|
|
1871
|
+
let pollInterval = null;
|
|
1872
|
+
function settle() {
|
|
1873
|
+
/* istanbul ignore next: observer is only null on fallback path (itself ignored) */
|
|
1874
|
+
if (observer) observer.disconnect();
|
|
1875
|
+
/* istanbul ignore next: fallback polling path only used when PerformanceObserver is unavailable */
|
|
1876
|
+
if (pollInterval) clearInterval(pollInterval);
|
|
1877
|
+
resolve({
|
|
1878
|
+
passed: true,
|
|
1879
|
+
duration_ms: Math.round(performance.now() - startTime)
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
function resetIdleTimer() {
|
|
1883
|
+
/* istanbul ignore next: timer is always set before any resource entry arrives */
|
|
1884
|
+
if (timer) clearTimeout(timer);
|
|
1885
|
+
timer = setTimeout(settle, networkIdleWindowMs);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
/* istanbul ignore next: observer callback body only runs if a network resource loads during the idle window */
|
|
1889
|
+
let observer = observePerformance('resource', entries => {
|
|
1890
|
+
if (aborted.value) return;
|
|
1891
|
+
if (entries.length > 0) resetIdleTimer();
|
|
1892
|
+
});
|
|
1893
|
+
|
|
1894
|
+
/* istanbul ignore next: PerformanceObserver fallback only triggers in older browsers */
|
|
1895
|
+
if (!observer) {
|
|
1896
|
+
let lastCount = performance.getEntriesByType('resource').length;
|
|
1897
|
+
pollInterval = setInterval(() => {
|
|
1898
|
+
if (aborted.value) {
|
|
1899
|
+
clearInterval(pollInterval);
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
let count = performance.getEntriesByType('resource').length;
|
|
1903
|
+
if (count !== lastCount) {
|
|
1904
|
+
lastCount = count;
|
|
1905
|
+
resetIdleTimer();
|
|
1906
|
+
}
|
|
1907
|
+
}, 50);
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// Start the initial idle window.
|
|
1911
|
+
timer = setTimeout(settle, networkIdleWindowMs);
|
|
1912
|
+
aborted.onAbort(() => {
|
|
1913
|
+
/* istanbul ignore next: observer is only null on fallback path (itself ignored) */
|
|
1914
|
+
if (observer) observer.disconnect();
|
|
1915
|
+
/* istanbul ignore next: pollInterval is only set on the fallback path */
|
|
1916
|
+
if (pollInterval) clearInterval(pollInterval);
|
|
1917
|
+
/* istanbul ignore next: timer is always set before abort can fire */
|
|
1918
|
+
if (timer) clearTimeout(timer);
|
|
1919
|
+
});
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
function checkFontReady(aborted) {
|
|
1923
|
+
var _document$fonts;
|
|
1924
|
+
let start = performance.now();
|
|
1925
|
+
/* istanbul ignore next: cannot mock document.fonts API in browser tests */
|
|
1926
|
+
if (!((_document$fonts = document.fonts) !== null && _document$fonts !== void 0 && _document$fonts.ready)) return Promise.resolve({
|
|
1927
|
+
passed: true,
|
|
1928
|
+
duration_ms: 0,
|
|
1929
|
+
skipped: true
|
|
1930
|
+
});
|
|
1931
|
+
let fontTimer;
|
|
1932
|
+
let resolveAbort;
|
|
1933
|
+
// Resolve deterministically on abort so the race is settled by the orchestrator's timeout
|
|
1934
|
+
// path and doesn't get retroactively flipped to { passed: true } when document.fonts.ready
|
|
1935
|
+
// settles late. Important if we ever begin reading checks.font_ready post-timeout.
|
|
1936
|
+
let abortPromise = new Promise(r => {
|
|
1937
|
+
resolveAbort = r;
|
|
1938
|
+
});
|
|
1939
|
+
let result = Promise.race([document.fonts.ready.then(() => ({
|
|
1940
|
+
passed: true,
|
|
1941
|
+
duration_ms: Math.round(performance.now() - start)
|
|
1942
|
+
})), /* istanbul ignore next: font timeout requires 5s delay, impractical in tests */
|
|
1943
|
+
new Promise(r => {
|
|
1944
|
+
fontTimer = setTimeout(() => r({
|
|
1945
|
+
passed: false,
|
|
1946
|
+
duration_ms: 5000,
|
|
1947
|
+
timed_out: true
|
|
1948
|
+
}), 5000);
|
|
1949
|
+
}), abortPromise]);
|
|
1950
|
+
/* istanbul ignore next: abort path not deterministically testable */
|
|
1951
|
+
if (aborted) {
|
|
1952
|
+
aborted.onAbort(() => {
|
|
1953
|
+
if (fontTimer) clearTimeout(fontTimer);
|
|
1954
|
+
resolveAbort({
|
|
1955
|
+
passed: false,
|
|
1956
|
+
duration_ms: Math.round(performance.now() - start),
|
|
1957
|
+
aborted: true
|
|
1958
|
+
});
|
|
1959
|
+
});
|
|
1960
|
+
}
|
|
1961
|
+
return result;
|
|
1962
|
+
}
|
|
1963
|
+
function checkImageReady(aborted) {
|
|
1964
|
+
return new Promise(resolve => {
|
|
1965
|
+
let start = performance.now();
|
|
1966
|
+
let vh = window.innerHeight;
|
|
1967
|
+
function getIncomplete() {
|
|
1968
|
+
let imgs = document.querySelectorAll('img');
|
|
1969
|
+
let incomplete = [];
|
|
1970
|
+
for (let img of imgs) {
|
|
1971
|
+
let r = img.getBoundingClientRect();
|
|
1972
|
+
/* istanbul ignore else: test images are always placed in the viewport with non-zero dimensions */
|
|
1973
|
+
if (r.top < vh && r.bottom > 0 && r.width > 0 && r.height > 0) {
|
|
1974
|
+
if (!img.complete || img.naturalWidth === 0) incomplete.push(img);
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
return incomplete;
|
|
1978
|
+
}
|
|
1979
|
+
let total = document.querySelectorAll('img').length;
|
|
1980
|
+
let incStart = getIncomplete().length;
|
|
1981
|
+
if (incStart === 0) {
|
|
1982
|
+
resolve({
|
|
1983
|
+
passed: true,
|
|
1984
|
+
duration_ms: 0,
|
|
1985
|
+
images_checked: total,
|
|
1986
|
+
images_incomplete_at_start: 0
|
|
1987
|
+
});
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
let interval = setInterval(() => {
|
|
1991
|
+
/* istanbul ignore next: abort clears the interval synchronously, defensive dead code in tests */
|
|
1992
|
+
if (aborted.value) {
|
|
1993
|
+
clearInterval(interval);
|
|
1994
|
+
return;
|
|
1995
|
+
}
|
|
1996
|
+
/* istanbul ignore next: requires network latency — images load synchronously in tests with data: URLs */
|
|
1997
|
+
if (getIncomplete().length === 0) {
|
|
1998
|
+
clearInterval(interval);
|
|
1999
|
+
resolve({
|
|
2000
|
+
passed: true,
|
|
2001
|
+
duration_ms: Math.round(performance.now() - start),
|
|
2002
|
+
images_checked: total,
|
|
2003
|
+
images_incomplete_at_start: incStart
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
}, 100);
|
|
2007
|
+
|
|
2008
|
+
/* istanbul ignore next: abort-on-timeout path; only fires when images never load in time */
|
|
2009
|
+
aborted.onAbort(() => clearInterval(interval));
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
function checkJSIdle(idleWindowMs, aborted) {
|
|
2013
|
+
// Three-tier JS idle detection — purely observational, no monkey-patching:
|
|
2014
|
+
// Tier 1: Long Task API (PerformanceObserver) — detects main-thread tasks >50ms
|
|
2015
|
+
// Tier 2: requestIdleCallback — confirms browser idle (fallback: setTimeout 200ms)
|
|
2016
|
+
// Tier 3: Double-requestAnimationFrame — ensures render/paint cycle is complete
|
|
2017
|
+
return new Promise(resolve => {
|
|
2018
|
+
let start = performance.now();
|
|
2019
|
+
let longTaskCount = 0;
|
|
2020
|
+
let idleTimer = null;
|
|
2021
|
+
let observer = null;
|
|
2022
|
+
let settled = false;
|
|
2023
|
+
let observing = false;
|
|
2024
|
+
|
|
2025
|
+
// Tier 1: Long Task API — reset idle timer on each observed long task.
|
|
2026
|
+
// observePerformance returns null on older browsers; we degrade to the
|
|
2027
|
+
// rIC/rAF-only path in that case.
|
|
2028
|
+
/* istanbul ignore next: longtask callback fires only on CPU-heavy >50ms tasks, not reliable in tests */
|
|
2029
|
+
observer = observePerformance('longtask', entries => {
|
|
2030
|
+
if (!observing || settled || aborted.value) return;
|
|
2031
|
+
for (let entry of entries) {
|
|
2032
|
+
if (entry.entryType === 'longtask') {
|
|
2033
|
+
longTaskCount++;
|
|
2034
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
2035
|
+
idleTimer = setTimeout(confirmIdle, idleWindowMs);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
});
|
|
2039
|
+
function cleanup() {
|
|
2040
|
+
settled = true;
|
|
2041
|
+
/* istanbul ignore next: defensive — observer is always set except when Long Task API fails (itself ignored) */
|
|
2042
|
+
if (observer) observer.disconnect();
|
|
2043
|
+
/* istanbul ignore next: defensive — idleTimer may be null between cleanup calls from multiple abort paths */
|
|
2044
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
2045
|
+
}
|
|
2046
|
+
function done(idleCallbackUsed) {
|
|
2047
|
+
/* istanbul ignore next: defensive — re-entry guard for race between done/cleanup/abort */
|
|
2048
|
+
if (settled || aborted.value) return;
|
|
2049
|
+
cleanup();
|
|
2050
|
+
resolve({
|
|
2051
|
+
passed: true,
|
|
2052
|
+
duration_ms: Math.round(performance.now() - start),
|
|
2053
|
+
long_tasks_observed: longTaskCount,
|
|
2054
|
+
idle_callback_used: idleCallbackUsed
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
// Tier 2: requestIdleCallback confirmation (or fallback)
|
|
2059
|
+
function confirmIdle() {
|
|
2060
|
+
/* istanbul ignore next: defensive re-entry guard — confirmIdle can be scheduled multiple times */
|
|
2061
|
+
if (settled || aborted.value) return;
|
|
2062
|
+
/* istanbul ignore else: rIC is available in modern Chrome/Firefox — fallback is for older browsers */
|
|
2063
|
+
if (typeof requestIdleCallback === 'function') {
|
|
2064
|
+
/* istanbul ignore next: rIC timeout only fires if requestIdleCallback takes longer than idleWindowMs * 2 — cleared by rIC callback in normal runs */
|
|
2065
|
+
let ricTimer = setTimeout(() => doubleRAF(false), idleWindowMs * 2);
|
|
2066
|
+
requestIdleCallback(() => {
|
|
2067
|
+
clearTimeout(ricTimer);
|
|
2068
|
+
doubleRAF(true);
|
|
2069
|
+
});
|
|
2070
|
+
aborted.onAbort(() => clearTimeout(ricTimer));
|
|
2071
|
+
} else {
|
|
2072
|
+
let fallbackTimer = setTimeout(() => doubleRAF(false), 200);
|
|
2073
|
+
aborted.onAbort(() => clearTimeout(fallbackTimer));
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
// Tier 3: Double-rAF render gate
|
|
2078
|
+
function doubleRAF(usedRIC) {
|
|
2079
|
+
/* istanbul ignore next: defensive re-entry guard — doubleRAF can be scheduled from multiple paths */
|
|
2080
|
+
if (settled || aborted.value) return;
|
|
2081
|
+
requestAnimationFrame(() => {
|
|
2082
|
+
requestAnimationFrame(() => {
|
|
2083
|
+
done(usedRIC);
|
|
2084
|
+
});
|
|
2085
|
+
});
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
// Start: skip first frame to avoid detecting Percy's own insertPercyDom() setup,
|
|
2089
|
+
// then begin idle window
|
|
2090
|
+
requestAnimationFrame(() => {
|
|
2091
|
+
/* istanbul ignore next: abort only fires during timeout race, not on first rAF in tests */
|
|
2092
|
+
if (aborted.value) return;
|
|
2093
|
+
observing = true;
|
|
2094
|
+
idleTimer = setTimeout(confirmIdle, idleWindowMs);
|
|
2095
|
+
});
|
|
2096
|
+
aborted.onAbort(() => cleanup());
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
function checkReadySelectors(selectors, aborted) {
|
|
2100
|
+
/* istanbul ignore next: orchestrator only calls this when selectors.length > 0; defensive for direct callers */
|
|
2101
|
+
if (!(selectors !== null && selectors !== void 0 && selectors.length)) return Promise.resolve({
|
|
2102
|
+
passed: true,
|
|
2103
|
+
duration_ms: 0,
|
|
2104
|
+
selectors: []
|
|
2105
|
+
});
|
|
2106
|
+
return new Promise(resolve => {
|
|
2107
|
+
let start = performance.now();
|
|
2108
|
+
function check() {
|
|
2109
|
+
for (let s of selectors) {
|
|
2110
|
+
let el = resolveSelector(s);
|
|
2111
|
+
if (!el) return false;
|
|
2112
|
+
if (el.offsetParent === null && getComputedStyle(el).position !== 'fixed' && getComputedStyle(el).position !== 'sticky') return false;
|
|
2113
|
+
}
|
|
2114
|
+
return true;
|
|
2115
|
+
}
|
|
2116
|
+
if (check()) {
|
|
2117
|
+
resolve({
|
|
2118
|
+
passed: true,
|
|
2119
|
+
duration_ms: 0,
|
|
2120
|
+
selectors
|
|
2121
|
+
});
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
2124
|
+
let interval = setInterval(() => {
|
|
2125
|
+
/* istanbul ignore next: abort clears the interval synchronously, defensive dead code in tests */
|
|
2126
|
+
if (aborted.value) {
|
|
2127
|
+
clearInterval(interval);
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
if (check()) {
|
|
2131
|
+
clearInterval(interval);
|
|
2132
|
+
resolve({
|
|
2133
|
+
passed: true,
|
|
2134
|
+
duration_ms: Math.round(performance.now() - start),
|
|
2135
|
+
selectors
|
|
2136
|
+
});
|
|
2137
|
+
}
|
|
2138
|
+
}, 100);
|
|
2139
|
+
aborted.onAbort(() => clearInterval(interval));
|
|
2140
|
+
});
|
|
2141
|
+
}
|
|
2142
|
+
function checkNotPresentSelectors(selectors, aborted) {
|
|
2143
|
+
/* istanbul ignore next: orchestrator only calls this when selectors.length > 0; defensive for direct callers */
|
|
2144
|
+
if (!(selectors !== null && selectors !== void 0 && selectors.length)) return Promise.resolve({
|
|
2145
|
+
passed: true,
|
|
2146
|
+
duration_ms: 0,
|
|
2147
|
+
selectors: []
|
|
2148
|
+
});
|
|
2149
|
+
return new Promise(resolve => {
|
|
2150
|
+
let start = performance.now();
|
|
2151
|
+
function check() {
|
|
2152
|
+
for (let s of selectors) {
|
|
2153
|
+
if (resolveSelector(s)) return false;
|
|
2154
|
+
}
|
|
2155
|
+
return true;
|
|
2156
|
+
}
|
|
2157
|
+
if (check()) {
|
|
2158
|
+
resolve({
|
|
2159
|
+
passed: true,
|
|
2160
|
+
duration_ms: 0,
|
|
2161
|
+
selectors
|
|
2162
|
+
});
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
2165
|
+
let interval = setInterval(() => {
|
|
2166
|
+
/* istanbul ignore next: abort clears the interval synchronously, defensive dead code in tests */
|
|
2167
|
+
if (aborted.value) {
|
|
2168
|
+
clearInterval(interval);
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
if (check()) {
|
|
2172
|
+
clearInterval(interval);
|
|
2173
|
+
resolve({
|
|
2174
|
+
passed: true,
|
|
2175
|
+
duration_ms: Math.round(performance.now() - start),
|
|
2176
|
+
selectors
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
2179
|
+
}, 100);
|
|
2180
|
+
|
|
2181
|
+
/* istanbul ignore next: abort-on-timeout path; only fires when the excluded selector never disappears */
|
|
2182
|
+
aborted.onAbort(() => clearInterval(interval));
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
// --- Orchestrator ---
|
|
2187
|
+
|
|
2188
|
+
// Simple abort controller for browser context (no AbortController dependency).
|
|
2189
|
+
// Exported for direct unit testing.
|
|
2190
|
+
function createAbortHandle() {
|
|
2191
|
+
let callbacks = [];
|
|
2192
|
+
return {
|
|
2193
|
+
value: false,
|
|
2194
|
+
onAbort(fn) {
|
|
2195
|
+
callbacks.push(fn);
|
|
2196
|
+
},
|
|
2197
|
+
abort() {
|
|
2198
|
+
this.value = true;
|
|
2199
|
+
callbacks.forEach(fn => fn());
|
|
2200
|
+
callbacks = [];
|
|
2201
|
+
}
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
async function runAllChecks(config, result, aborted) {
|
|
2205
|
+
var _config$ready_selecto, _config$not_present_s;
|
|
2206
|
+
let checks = [];
|
|
2207
|
+
let expected = [];
|
|
2208
|
+
// dom_stability: false is an explicit kill switch for the MutationObserver
|
|
2209
|
+
// check. Use it on heavy SPA pages where the observer itself can drive
|
|
2210
|
+
// CPU/memory pressure. Other checks (js_idle, image/font ready, selectors)
|
|
2211
|
+
// continue to run, so capture is still gated — just not on raw mutation rate.
|
|
2212
|
+
if (config.dom_stability !== false && config.stability_window_ms > 0) {
|
|
2213
|
+
expected.push('dom_stability');
|
|
2214
|
+
checks.push(checkDOMStability(config.stability_window_ms, aborted).then(r => {
|
|
2215
|
+
result.checks.dom_stability = r;
|
|
2216
|
+
}));
|
|
2217
|
+
}
|
|
2218
|
+
if (config.network_idle_window_ms > 0) {
|
|
2219
|
+
expected.push('network_idle');
|
|
2220
|
+
checks.push(checkNetworkIdle(config.network_idle_window_ms, aborted).then(r => {
|
|
2221
|
+
result.checks.network_idle = r;
|
|
2222
|
+
}));
|
|
2223
|
+
}
|
|
2224
|
+
if (config.font_ready !== false) {
|
|
2225
|
+
expected.push('font_ready');
|
|
2226
|
+
checks.push(checkFontReady(aborted).then(r => {
|
|
2227
|
+
result.checks.font_ready = r;
|
|
2228
|
+
}));
|
|
2229
|
+
}
|
|
2230
|
+
if (config.image_ready !== false) {
|
|
2231
|
+
expected.push('image_ready');
|
|
2232
|
+
checks.push(checkImageReady(aborted).then(r => {
|
|
2233
|
+
result.checks.image_ready = r;
|
|
2234
|
+
}));
|
|
2235
|
+
}
|
|
2236
|
+
if (config.js_idle !== false) {
|
|
2237
|
+
expected.push('js_idle');
|
|
2238
|
+
// Fall back to stability_window_ms if js_idle_window_ms is not set.
|
|
2239
|
+
// All built-in presets set js_idle_window_ms, so this fallback only
|
|
2240
|
+
// fires when a caller passes a custom config that predates the
|
|
2241
|
+
// dedicated option — preserves backward compatibility.
|
|
2242
|
+
/* istanbul ignore next: fallback only hit by pre-js_idle_window_ms configs; built-in presets always set it */
|
|
2243
|
+
let jsIdleWindow = config.js_idle_window_ms ?? config.stability_window_ms;
|
|
2244
|
+
checks.push(checkJSIdle(jsIdleWindow, aborted).then(r => {
|
|
2245
|
+
result.checks.js_idle = r;
|
|
2246
|
+
}));
|
|
2247
|
+
}
|
|
2248
|
+
if ((_config$ready_selecto = config.ready_selectors) !== null && _config$ready_selecto !== void 0 && _config$ready_selecto.length) {
|
|
2249
|
+
expected.push('ready_selectors');
|
|
2250
|
+
checks.push(checkReadySelectors(config.ready_selectors, aborted).then(r => {
|
|
2251
|
+
result.checks.ready_selectors = r;
|
|
2252
|
+
}));
|
|
2253
|
+
}
|
|
2254
|
+
if ((_config$not_present_s = config.not_present_selectors) !== null && _config$not_present_s !== void 0 && _config$not_present_s.length) {
|
|
2255
|
+
expected.push('not_present_selectors');
|
|
2256
|
+
checks.push(checkNotPresentSelectors(config.not_present_selectors, aborted).then(r => {
|
|
2257
|
+
result.checks.not_present_selectors = r;
|
|
2258
|
+
}));
|
|
2259
|
+
}
|
|
2260
|
+
result._expectedChecks = expected;
|
|
2261
|
+
await Promise.all(checks);
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
// Normalize camelCase config keys (from .percy.yml / SDK options) to the
|
|
2265
|
+
// snake_case keys used internally. Accepts either naming.
|
|
2266
|
+
// Exported for direct unit testing.
|
|
2267
|
+
function normalizeOptions(options = {}) {
|
|
2268
|
+
return {
|
|
2269
|
+
preset: options.preset,
|
|
2270
|
+
stability_window_ms: options.stabilityWindowMs ?? options.stability_window_ms,
|
|
2271
|
+
js_idle_window_ms: options.jsIdleWindowMs ?? options.js_idle_window_ms,
|
|
2272
|
+
network_idle_window_ms: options.networkIdleWindowMs ?? options.network_idle_window_ms,
|
|
2273
|
+
timeout_ms: options.timeoutMs ?? options.timeout_ms,
|
|
2274
|
+
dom_stability: options.domStability ?? options.dom_stability,
|
|
2275
|
+
image_ready: options.imageReady ?? options.image_ready,
|
|
2276
|
+
font_ready: options.fontReady ?? options.font_ready,
|
|
2277
|
+
js_idle: options.jsIdle ?? options.js_idle,
|
|
2278
|
+
ready_selectors: options.readySelectors ?? options.ready_selectors,
|
|
2279
|
+
not_present_selectors: options.notPresentSelectors ?? options.not_present_selectors,
|
|
2280
|
+
max_timeout_ms: options.maxTimeoutMs ?? options.max_timeout_ms
|
|
2281
|
+
};
|
|
2282
|
+
}
|
|
2283
|
+
async function waitForReady(options = {}) {
|
|
2284
|
+
let presetName = options.preset || 'balanced';
|
|
2285
|
+
if (presetName === 'disabled') return {
|
|
2286
|
+
passed: true,
|
|
2287
|
+
timed_out: false,
|
|
2288
|
+
skipped: true,
|
|
2289
|
+
checks: {}
|
|
2290
|
+
};
|
|
2291
|
+
let preset = PRESETS[presetName] || PRESETS.balanced;
|
|
2292
|
+
// Normalize user options to snake_case, then merge. Only overrides
|
|
2293
|
+
// where user explicitly provided a value (undefined keys don't overwrite).
|
|
2294
|
+
let userOptions = normalizeOptions(options);
|
|
2295
|
+
let config = {
|
|
2296
|
+
...preset
|
|
2297
|
+
};
|
|
2298
|
+
for (let key of Object.keys(userOptions)) {
|
|
2299
|
+
if (userOptions[key] !== undefined) config[key] = userOptions[key];
|
|
2300
|
+
}
|
|
2301
|
+
let effectiveTimeout = config.max_timeout_ms ? Math.min(config.timeout_ms, config.max_timeout_ms) : config.timeout_ms;
|
|
2302
|
+
let startTime = performance.now();
|
|
2303
|
+
let result = {
|
|
2304
|
+
passed: false,
|
|
2305
|
+
timed_out: false,
|
|
2306
|
+
preset: presetName,
|
|
2307
|
+
checks: {}
|
|
2308
|
+
};
|
|
2309
|
+
let settled = false;
|
|
2310
|
+
let aborted = createAbortHandle();
|
|
2311
|
+
try {
|
|
2312
|
+
await Promise.race([runAllChecks(config, result, aborted).then(() => {
|
|
2313
|
+
settled = true;
|
|
2314
|
+
}), new Promise(resolve => setTimeout(() => {
|
|
2315
|
+
if (!settled) {
|
|
2316
|
+
result.timed_out = true;
|
|
2317
|
+
// Abort all running checks — clears intervals, disconnects observers
|
|
2318
|
+
aborted.abort();
|
|
2319
|
+
}
|
|
2320
|
+
resolve();
|
|
2321
|
+
}, effectiveTimeout))]);
|
|
2322
|
+
} catch (error) {
|
|
2323
|
+
/* istanbul ignore next: safety net for unexpected errors in readiness checks */
|
|
2324
|
+
result.error = error.message || String(error);
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
// Mark any checks that didn't complete before timeout as failed.
|
|
2328
|
+
// `_expectedChecks` is always set by runAllChecks, but coverage here
|
|
2329
|
+
// depends on whether any expected check was skipped due to timeout.
|
|
2330
|
+
/* istanbul ignore next: only falsy when the catch block above fires before runAllChecks sets _expectedChecks */
|
|
2331
|
+
if (result._expectedChecks) {
|
|
2332
|
+
for (let name of result._expectedChecks) {
|
|
2333
|
+
if (!result.checks[name]) {
|
|
2334
|
+
result.checks[name] = {
|
|
2335
|
+
passed: false,
|
|
2336
|
+
timed_out: true
|
|
2337
|
+
};
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
delete result._expectedChecks;
|
|
2341
|
+
}
|
|
2342
|
+
result.total_duration_ms = Math.round(performance.now() - startTime);
|
|
2343
|
+
result.passed = !result.timed_out && !result.error && Object.values(result.checks).every(c => c.passed);
|
|
2344
|
+
return result;
|
|
2345
|
+
}
|
|
2346
|
+
|
|
1664
2347
|
exports["default"] = serializeDOM;
|
|
1665
2348
|
exports.loadAllSrcsetLinks = loadAllSrcsetLinks;
|
|
1666
2349
|
exports.serialize = serializeDOM;
|
|
1667
2350
|
exports.serializeDOM = serializeDOM;
|
|
2351
|
+
exports.waitForReady = waitForReady;
|
|
1668
2352
|
exports.waitForResize = waitForResize;
|
|
1669
2353
|
|
|
1670
2354
|
Object.defineProperty(exports, '__esModule', { value: true });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@percy/dom",
|
|
3
|
-
"version": "1.31.14-beta.
|
|
3
|
+
"version": "1.31.14-beta.5",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -35,5 +35,5 @@
|
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"interactor.js": "^2.0.0-beta.10"
|
|
37
37
|
},
|
|
38
|
-
"gitHead": "
|
|
38
|
+
"gitHead": "5a92d9e34d219d2c9a46d74a020de81814973370"
|
|
39
39
|
}
|