@percy/dom 1.31.14-beta.3 → 1.31.14-beta.4

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