@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.
Files changed (2) hide show
  1. package/dist/bundle.js +686 -2
  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
- if (lenA !== lenB) return false;
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
- // Serializes a document and returns the resulting DOM string.
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",
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": "a17d4a1453c6bef282fd3da38082b670e125a5be"
38
+ "gitHead": "5a92d9e34d219d2c9a46d74a020de81814973370"
39
39
  }