Module: Capybara::Storyboard::PageStability
- Defined in:
- lib/capybara/storyboard/page_stability.rb
Overview
Waits for a page to become visually stable before a screenshot is taken:
no running CSS/JS animations (via document.getAnimations()) AND no DOM
mutations for at least interval seconds (tracked by a MutationObserver).
Ported from SonicGarden/wlb-morning-mail's capybara_screenshot_helper.rb.
Stateless, so exposed as module functions. On a non-JS driver (Rack::Test) or when the driver rejects the script, the whole thing is a safe no-op: a failed wait must never prevent the screenshot itself from being taken.
Constant Summary collapse
- CHECK_SCRIPT =
Reports the running-animation count and time since the last DOM mutation. Static (no interpolation), so built once and reused on every poll.
<<~JS (function() { const timeSinceLastMutation = Date.now() - window._lastMutationTime; const excludedAnimations = window._excludedAnimations ?? []; const animations = document.getAnimations(); const runningAnimations = animations.filter(animation => { // getAnimations() keeps finished/paused animations attached to the // element until they are cancelled or the node is removed, so only // count the ones actually playing. if (animation.playState !== 'running') { return false; } if (animation instanceof CSSAnimation) { return !excludedAnimations.includes(animation.animationName); } return true; }).length; return { timeSinceLastMutation: timeSinceLastMutation, runningAnimations: runningAnimations }; })(); JS
- CLEANUP_SCRIPT =
Tears down the observer and its globals. Static, so built once and reused.
<<~JS if (window._pageStabilityObserver) { window._pageStabilityObserver.disconnect(); delete window._pageStabilityObserver; delete window._lastMutationTime; delete window._excludedAnimations; } JS
Class Method Summary collapse
- .check(page) ⇒ Object
-
.cleanup(page) ⇒ Object
Best-effort teardown so no globals leak between screenshots.
-
.finite_number?(value) ⇒ Boolean
True only for a real, finite number.
-
.measurable?(result) ⇒ Boolean
True when the poll returned real numbers to compare.
-
.non_js_driver_error?(error) ⇒ Boolean
True when
errormeans "this driver can't run our JS" — the expected, non-alarming case (e.g. Rack::Test) that should skip silently rather than warn. - .setup(page, excluded_animations) ⇒ Object
-
.stable?(result, interval) ⇒ Boolean
evaluate_script returns string-keyed hashes on the real drivers; the DOM-quiet window is measured in ms, so compare against interval * 1000.
-
.wait_for_stable_page(page, interval:, max_attempts:, excluded_animations:) ⇒ Object
Polls
pageuntil stable ormax_attemptsis reached. -
.warn_unstable(result, interval) ⇒ Object
Warns using the last observed poll result (nil when max_attempts is 0), so no extra JS round-trip is made and the reported numbers match the values that actually failed the stability check.
Class Method Details
.check(page) ⇒ Object
103 104 105 |
# File 'lib/capybara/storyboard/page_stability.rb', line 103 def check(page) page.evaluate_script(CHECK_SCRIPT) end |
.cleanup(page) ⇒ Object
Best-effort teardown so no globals leak between screenshots. Runs from an ensure block, so it must never raise even when setup never happened or the driver can't run JS.
110 111 112 113 114 |
# File 'lib/capybara/storyboard/page_stability.rb', line 110 def cleanup(page) page.execute_script(CLEANUP_SCRIPT) rescue StandardError nil end |
.finite_number?(value) ⇒ Boolean
True only for a real, finite number. Guards against nil (Selenium serializes a navigation-reset NaN to JSON null) and against Float::NAN / Infinity (CDP drivers such as Cuprite/Ferrum decode the reset NaN back into a literal Float::NAN), both of which mean "the measurement was lost and must be re-armed".
130 131 132 |
# File 'lib/capybara/storyboard/page_stability.rb', line 130 def finite_number?(value) value.is_a?(Numeric) && (!value.is_a?(Float) || value.finite?) end |
.measurable?(result) ⇒ Boolean
True when the poll returned real numbers to compare. A page navigation between setup and a poll swaps in a fresh document whose window._lastMutationTime is undefined, so Date.now() - undefined === NaN. Callers use this to decide whether to re-arm the observer (Ruby side) and whether the numeric comparison in #stable? is even meaningful.
121 122 123 |
# File 'lib/capybara/storyboard/page_stability.rb', line 121 def measurable?(result) finite_number?(result['runningAnimations']) && finite_number?(result['timeSinceLastMutation']) end |
.non_js_driver_error?(error) ⇒ Boolean
True when error means "this driver can't run our JS" — the expected,
non-alarming case (e.g. Rack::Test) that should skip silently rather
than warn. Capybara is optional at load time (the gem's own specs don't
require it) and Selenium may be absent, so resolve each constant only
when defined; an undefined constant simply never matches.
161 162 163 164 165 166 167 168 |
# File 'lib/capybara/storyboard/page_stability.rb', line 161 def non_js_driver_error?(error) return true if defined?(Capybara::NotSupportedByDriverError) && error.is_a?(Capybara::NotSupportedByDriverError) return true if defined?(Selenium::WebDriver::Error::JavascriptError) && error.is_a?(Selenium::WebDriver::Error::JavascriptError) false end |
.setup(page, excluded_animations) ⇒ Object
90 91 92 93 94 95 96 97 98 99 100 101 |
# File 'lib/capybara/storyboard/page_stability.rb', line 90 def setup(page, excluded_animations) page.execute_script(<<~JS) window._lastMutationTime = Date.now(); window._excludedAnimations = #{excluded_animations.to_json}; window._pageStabilityObserver = new MutationObserver(() => { window._lastMutationTime = Date.now(); }); window._pageStabilityObserver.observe(document.body, { childList: true, subtree: true, attributes: true, characterData: true }); JS end |
.stable?(result, interval) ⇒ Boolean
evaluate_script returns string-keyed hashes on the real drivers; the DOM-quiet window is measured in ms, so compare against interval * 1000. A non-numeric result (e.g. the measurement was reset by a page navigation) is treated as "not stable yet", which also keeps this comparison free of NoMethodError regardless of what the driver hands back.
139 140 141 142 143 |
# File 'lib/capybara/storyboard/page_stability.rb', line 139 def stable?(result, interval) return false unless measurable?(result) result['runningAnimations'].zero? && result['timeSinceLastMutation'] >= (interval * 1000) end |
.wait_for_stable_page(page, interval:, max_attempts:, excluded_animations:) ⇒ Object
Polls page until stable or max_attempts is reached. When the limit
is hit the page is deemed "good enough": a warning is printed to STDERR
and control returns normally (no exception), so capture proceeds.
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 |
# File 'lib/capybara/storyboard/page_stability.rb', line 55 def wait_for_stable_page(page, interval:, max_attempts:, excluded_animations:) setup(page, excluded_animations) result = nil stable = false max_attempts.times do |attempt| result = check(page) stable = stable?(result, interval) break if stable # No point re-arming or sleeping after the final check — cleanup runs # immediately after and nothing re-checks it. next if attempt == max_attempts - 1 # The measurement was reset by a page navigation (non-numeric result); # re-arm the observer with the configured excluded animations so the # next poll can measure again. setup(page, excluded_animations) unless measurable?(result) sleep(interval) end # Unstable (or max_attempts was 0): the page is deemed good enough. warn_unstable(result, interval) unless stable rescue StandardError => e # A failed wait must never prevent the screenshot from being taken, so # swallow every error and let capture proceed. Expected "this driver # can't run JS" errors (e.g. Rack::Test) are silent; anything else is # surfaced via warn so a real driver problem stays visible. warn("capybara-storyboard: page stability wait skipped after error: #{e.class}: #{e.}") unless non_js_driver_error?(e) nil ensure cleanup(page) end |
.warn_unstable(result, interval) ⇒ Object
Warns using the last observed poll result (nil when max_attempts is 0), so no extra JS round-trip is made and the reported numbers match the values that actually failed the stability check.
148 149 150 151 152 153 154 |
# File 'lib/capybara/storyboard/page_stability.rb', line 148 def warn_unstable(result, interval) warn( 'capybara-storyboard: page did not become stable before the screenshot ' \ "(runningAnimations=#{result && result['runningAnimations']}, " \ "timeSinceLastMutation=#{result && result['timeSinceLastMutation']}ms, threshold=#{interval * 1000}ms)." ) end |