Module: Testivai::Capture
- Defined in:
- lib/testivai/capture.rb
Overview
Capture half of the pipeline: writes .testivai/temp/<name>/ with the
screenshot, the DOM snapshot, and the element map. The compare half is
npx testivai report, shared with every other adapter.
Works against any Capybara session or a bare Selenium driver — all this
needs is execute_script plus some way to take a screenshot, which is
exactly what every Capybara driver already exposes.
Constant Summary collapse
- STYLE_ID =
"__testivai_capture_style__"- STABILIZE_CSS =
Collapses animations and transitions to ~0s, hides the caret, and disables smooth scrolling — the top causes of flaky visual diffs. Near-zero rather than
noneso animations land on their final frame instead of never rendering. <<~CSS *, *::before, *::after { animation-duration: 0.001s !important; animation-delay: 0s !important; animation-iteration-count: 1 !important; transition-duration: 0.001s !important; transition-delay: 0s !important; caret-color: transparent !important; scroll-behavior: auto !important; } CSS
- INJECT_STYLE_JS =
<<~JS var s = document.createElement('style'); s.id = arguments[0]; s.textContent = arguments[1]; document.head.appendChild(s); JS
- REMOVE_STYLE_JS =
<<~JS var s = document.getElementById(arguments[0]); if (s && s.parentNode) { s.parentNode.removeChild(s); } JS
- FONTS_READY_JS =
<<~JS return !document.fonts || document.fonts.status === 'loaded'; JS
- DOM_SNAPSHOT_JS =
<<~JS var clone = document.documentElement.cloneNode(true); var sels = arguments[0] || []; for (var i = 0; i < sels.length; i++) { try { var found = clone.querySelectorAll(sels[i]); for (var j = 0; j < found.length; j++) { found[j].remove(); } } catch (e) {} } return clone.outerHTML; JS
Class Method Summary collapse
-
.capture_screenshot(browser) ⇒ Object
Full page where the browser offers it, viewport otherwise.
- .hide_css(selectors) ⇒ Object
- .inject_css(browser, css) ⇒ Object
- .remove_css(browser) ⇒ Object
-
.resolve_browser(session) ⇒ Object
Capybara sessions wrap the real driver; a bare Selenium driver is already what we want.
- .wait_for_fonts(browser, timeout: 10.0) ⇒ Object
-
.witness(session, name, ignore_selectors: nil, stabilize: nil, skip_dom: false, skip_element_map: false, max_elements: nil, project_root: nil) ⇒ Pathname
Capture a snapshot.
Class Method Details
.capture_screenshot(browser) ⇒ Object
Full page where the browser offers it, viewport otherwise.
- Chromium: CDP Page.captureScreenshot with captureBeyondViewport,
the same mechanism Playwright uses — a true full-page capture with
no window resizing (resizing breaks 100vh layouts).
- Firefox: selenium-webdriver exposes save_full_page_screenshot.
- anything else: the plain viewport screenshot.
193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 |
# File 'lib/testivai/capture.rb', line 193 def capture_screenshot(browser) if browser.respond_to?(:execute_cdp) begin result = browser.execute_cdp("Page.captureScreenshot", "captureBeyondViewport" => true, "format" => "png") data = result && (result["data"] || result[:data]) return Base64.decode64(data) if data rescue StandardError # rubocop:disable Lint/SuppressedException end end if browser.respond_to?(:full_screenshot_as) begin return browser.full_screenshot_as(:png) rescue StandardError # rubocop:disable Lint/SuppressedException end end browser.screenshot_as(:png) end |
.hide_css(selectors) ⇒ Object
159 160 161 |
# File 'lib/testivai/capture.rb', line 159 def hide_css(selectors) selectors.map { |s| "#{s} { visibility: hidden !important; }" }.join("\n") end |
.inject_css(browser, css) ⇒ Object
163 164 165 166 167 168 |
# File 'lib/testivai/capture.rb', line 163 def inject_css(browser, css) browser.execute_script(INJECT_STYLE_JS, STYLE_ID, css) true rescue StandardError false end |
.remove_css(browser) ⇒ Object
170 171 172 173 |
# File 'lib/testivai/capture.rb', line 170 def remove_css(browser) browser.execute_script(REMOVE_STYLE_JS, STYLE_ID) rescue StandardError # rubocop:disable Lint/SuppressedException end |
.resolve_browser(session) ⇒ Object
Capybara sessions wrap the real driver; a bare Selenium driver is already what we want. Accept both so this works in RSpec/Capybara, Cucumber, or plain selenium-webdriver.
152 153 154 155 156 157 |
# File 'lib/testivai/capture.rb', line 152 def resolve_browser(session) return session.driver.browser if session.respond_to?(:driver) && session.driver.respond_to?(:browser) return session.browser if session.respond_to?(:browser) session end |
.wait_for_fonts(browser, timeout: 10.0) ⇒ Object
175 176 177 178 179 180 181 182 183 184 |
# File 'lib/testivai/capture.rb', line 175 def wait_for_fonts(browser, timeout: 10.0) deadline = Time.now + timeout loop do return if browser.execute_script(FONTS_READY_JS) return if Time.now > deadline sleep 0.05 end rescue StandardError # rubocop:disable Lint/SuppressedException end |
.witness(session, name, ignore_selectors: nil, stabilize: nil, skip_dom: false, skip_element_map: false, max_elements: nil, project_root: nil) ⇒ Pathname
Capture a snapshot.
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 |
# File 'lib/testivai/capture.rb', line 75 def witness(session, name, ignore_selectors: nil, stabilize: nil, skip_dom: false, skip_element_map: false, max_elements: nil, project_root: nil) browser = resolve_browser(session) root = Pathname.new(project_root || Config.project_root) config = Config.load(root) selectors = ((config["ignoreSelectors"] || []) + (ignore_selectors || [])).uniq stabilize = config["stabilize"] if stabilize.nil? stabilize = true if stabilize.nil? temp_dir = root.join(".testivai", "temp", name.to_s) FileUtils.mkdir_p(temp_dir) css = +"" css << STABILIZE_CSS if stabilize css << hide_css(selectors) unless selectors.empty? injected = false unless css.empty? injected = inject_css(browser, css) if stabilize wait_for_fonts(browser) # Then wait for the page itself to stop changing — images finished, # DOM quiet. The load question the DOM/style layer cannot answer. Settle.wait_for(browser) end end begin screenshot = capture_screenshot(browser) ensure remove_css(browser) if injected Settle.stop(browser) if stabilize end File.binwrite(temp_dir.join("screenshot.png"), screenshot) # DOM snapshot — best-effort. Losing it only costs the noise hint; # it must never break the screenshot path. unless skip_dom begin dom = browser.execute_script(DOM_SNAPSHOT_JS, selectors) temp_dir.join("dom.html").write(dom) if dom.is_a?(String) && !dom.empty? rescue StandardError # rubocop:disable Lint/SuppressedException end end # Element map — same best-effort contract. Powers region→selector # attribution, shift classification, and the style fingerprint. unless skip_element_map begin expr = ElementMap.expression(max_elements || ElementMap::DEFAULT_MAX_ELEMENTS, selectors) map = browser.execute_script(expr) if map.is_a?(Array) && !map.empty? temp_dir.join("elements.json").write(JSON.generate(map)) end rescue StandardError # rubocop:disable Lint/SuppressedException end end # Shard manifest — refreshed per capture because RSpec offers the gem no # end-of-run hook. This is what lets a Ruby suite join the same sharded # CI flow as every other adapter. shard = Shard.from_env Shard.write_manifest(root.join(".testivai", "temp"), shard) if shard temp_dir end |