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.
179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 |
# File 'lib/testivai/capture.rb', line 179 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
145 146 147 |
# File 'lib/testivai/capture.rb', line 145 def hide_css(selectors) selectors.map { |s| "#{s} { visibility: hidden !important; }" }.join("\n") end |
.inject_css(browser, css) ⇒ Object
149 150 151 152 153 154 |
# File 'lib/testivai/capture.rb', line 149 def inject_css(browser, css) browser.execute_script(INJECT_STYLE_JS, STYLE_ID, css) true rescue StandardError false end |
.remove_css(browser) ⇒ Object
156 157 158 159 |
# File 'lib/testivai/capture.rb', line 156 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.
138 139 140 141 142 143 |
# File 'lib/testivai/capture.rb', line 138 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
161 162 163 164 165 166 167 168 169 170 |
# File 'lib/testivai/capture.rb', line 161 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.
73 74 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 |
# File 'lib/testivai/capture.rb', line 73 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) wait_for_fonts(browser) if stabilize end begin screenshot = capture_screenshot(browser) ensure remove_css(browser) if injected 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 temp_dir end |