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 none so 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

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.

Parameters:

  • session

    a Capybara session (page) or a Selenium driver

  • name

    snapshot name; becomes the directory under temp/

Returns:

  • (Pathname)

    the temp directory written



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