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.


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.

Parameters:

  • session

    a Capybara session (page) or a Selenium driver

  • name

    snapshot name; becomes the directory under temp/

Returns:

  • (Pathname)

    the temp directory written



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