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.
-
.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
96 97 98 |
# File 'lib/capybara/storyboard/page_stability.rb', line 96 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.
103 104 105 106 107 |
# File 'lib/capybara/storyboard/page_stability.rb', line 103 def cleanup(page) page.execute_script(CLEANUP_SCRIPT) rescue StandardError nil 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.
131 132 133 134 135 136 137 138 |
# File 'lib/capybara/storyboard/page_stability.rb', line 131 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
83 84 85 86 87 88 89 90 91 92 93 94 |
# File 'lib/capybara/storyboard/page_stability.rb', line 83 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.
111 112 113 |
# File 'lib/capybara/storyboard/page_stability.rb', line 111 def stable?(result, interval) 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 |
# 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 sleeping after the final check — nothing re-checks it. sleep(interval) unless attempt == max_attempts - 1 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.
118 119 120 121 122 123 124 |
# File 'lib/capybara/storyboard/page_stability.rb', line 118 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 |