testivai 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +62 -0
- data/lib/testivai/capture.rb +200 -0
- data/lib/testivai/config.rb +37 -0
- data/lib/testivai/element_map.js +135 -0
- data/lib/testivai/element_map.rb +50 -0
- data/lib/testivai/version.rb +5 -0
- data/lib/testivai.rb +25 -0
- metadata +59 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 65bd512ddfb0a96d7d0be93240ed9b5f62ffe6a22069062ec140e3a8270bfd14
|
|
4
|
+
data.tar.gz: 9dbc5d2cfc6104649f47147e60d1dc7669e7a12fbf8ca53a8978f2c044e3f38c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d58f7f2eac3856e27e0f446503cf2c43adeb68c623fe421277a7df87df12896216518a4892c9c07f6c3a1f3b075081ec0d49b4e097ace983785f7c5cce23e428
|
|
7
|
+
data.tar.gz: 3101cc06177e403c240f47380f3e0785e941dc7acabf9a0170bf656435a4a4b361a3236e5c41dc50c2c250d47a5b68ebee99b02ed237ed3500fe377412653b8d
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 TestivAI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# testivai (Ruby)
|
|
2
|
+
|
|
3
|
+
Local-first visual regression testing for Capybara and Selenium suites.
|
|
4
|
+
Part of [testivai-oss](https://github.com/mcbuddy/testivai-oss) — MIT, no
|
|
5
|
+
account, nothing uploaded.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# Gemfile
|
|
9
|
+
gem "testivai", group: :test
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
require "testivai"
|
|
14
|
+
|
|
15
|
+
RSpec.describe "Homepage", type: :feature do
|
|
16
|
+
it "looks right" do
|
|
17
|
+
visit "/"
|
|
18
|
+
Testivai.witness(page, "homepage")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Run your suite exactly as you already do, then compare:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
bundle exec rspec
|
|
27
|
+
npx testivai report
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The first run writes baselines under `.testivai/baselines/` — commit them.
|
|
31
|
+
Later runs diff against them and write `visual-report/index.html`.
|
|
32
|
+
|
|
33
|
+
## Options
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
Testivai.witness(page, "checkout",
|
|
37
|
+
ignore_selectors: [".live-chat"], # hidden from pixels and the DOM signal
|
|
38
|
+
stabilize: true, # freeze animations, hide caret, await fonts
|
|
39
|
+
skip_dom: false, # DOM snapshot powers the noise hint
|
|
40
|
+
skip_element_map: false, # element map powers selector attribution
|
|
41
|
+
max_elements: 3000)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## What it captures
|
|
45
|
+
|
|
46
|
+
| File | Purpose |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `screenshot.png` | Full page on Chromium (CDP) and Firefox; viewport elsewhere |
|
|
49
|
+
| `dom.html` | Powers the noise hint and text-change detection |
|
|
50
|
+
| `elements.json` | Powers region→selector attribution, shift detection, style checks |
|
|
51
|
+
|
|
52
|
+
The element-map collector is generated from one TypeScript source shared by
|
|
53
|
+
every TestivAI adapter, so Ruby, Python, Java, and JavaScript lanes produce
|
|
54
|
+
identical maps against the same baselines.
|
|
55
|
+
|
|
56
|
+
## Why `npx testivai report`?
|
|
57
|
+
|
|
58
|
+
Capture is native Ruby; the diff engine and HTML report are shared across all
|
|
59
|
+
languages and live in the `@testivai/witness` npm package. Same split as the
|
|
60
|
+
Python and Java adapters — one comparison implementation, not five.
|
|
61
|
+
|
|
62
|
+
Docs: <https://testiv.ai/docs/frameworks/ruby/>
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "pathname"
|
|
7
|
+
|
|
8
|
+
require_relative "config"
|
|
9
|
+
require_relative "element_map"
|
|
10
|
+
|
|
11
|
+
module Testivai
|
|
12
|
+
# Capture half of the pipeline: writes `.testivai/temp/<name>/` with the
|
|
13
|
+
# screenshot, the DOM snapshot, and the element map. The compare half is
|
|
14
|
+
# `npx testivai report`, shared with every other adapter.
|
|
15
|
+
#
|
|
16
|
+
# Works against any Capybara session or a bare Selenium driver — all this
|
|
17
|
+
# needs is `execute_script` plus some way to take a screenshot, which is
|
|
18
|
+
# exactly what every Capybara driver already exposes.
|
|
19
|
+
module Capture
|
|
20
|
+
STYLE_ID = "__testivai_capture_style__"
|
|
21
|
+
|
|
22
|
+
# Collapses animations and transitions to ~0s, hides the caret, and
|
|
23
|
+
# disables smooth scrolling — the top causes of flaky visual diffs.
|
|
24
|
+
# Near-zero rather than `none` so animations land on their final frame
|
|
25
|
+
# instead of never rendering.
|
|
26
|
+
STABILIZE_CSS = <<~CSS
|
|
27
|
+
*, *::before, *::after {
|
|
28
|
+
animation-duration: 0.001s !important;
|
|
29
|
+
animation-delay: 0s !important;
|
|
30
|
+
animation-iteration-count: 1 !important;
|
|
31
|
+
transition-duration: 0.001s !important;
|
|
32
|
+
transition-delay: 0s !important;
|
|
33
|
+
caret-color: transparent !important;
|
|
34
|
+
scroll-behavior: auto !important;
|
|
35
|
+
}
|
|
36
|
+
CSS
|
|
37
|
+
|
|
38
|
+
INJECT_STYLE_JS = <<~JS
|
|
39
|
+
var s = document.createElement('style');
|
|
40
|
+
s.id = arguments[0];
|
|
41
|
+
s.textContent = arguments[1];
|
|
42
|
+
document.head.appendChild(s);
|
|
43
|
+
JS
|
|
44
|
+
|
|
45
|
+
REMOVE_STYLE_JS = <<~JS
|
|
46
|
+
var s = document.getElementById(arguments[0]);
|
|
47
|
+
if (s && s.parentNode) { s.parentNode.removeChild(s); }
|
|
48
|
+
JS
|
|
49
|
+
|
|
50
|
+
FONTS_READY_JS = <<~JS
|
|
51
|
+
return !document.fonts || document.fonts.status === 'loaded';
|
|
52
|
+
JS
|
|
53
|
+
|
|
54
|
+
DOM_SNAPSHOT_JS = <<~JS
|
|
55
|
+
var clone = document.documentElement.cloneNode(true);
|
|
56
|
+
var sels = arguments[0] || [];
|
|
57
|
+
for (var i = 0; i < sels.length; i++) {
|
|
58
|
+
try {
|
|
59
|
+
var found = clone.querySelectorAll(sels[i]);
|
|
60
|
+
for (var j = 0; j < found.length; j++) { found[j].remove(); }
|
|
61
|
+
} catch (e) {}
|
|
62
|
+
}
|
|
63
|
+
return clone.outerHTML;
|
|
64
|
+
JS
|
|
65
|
+
|
|
66
|
+
module_function
|
|
67
|
+
|
|
68
|
+
# Capture a snapshot.
|
|
69
|
+
#
|
|
70
|
+
# @param session a Capybara session (`page`) or a Selenium driver
|
|
71
|
+
# @param name snapshot name; becomes the directory under temp/
|
|
72
|
+
# @return [Pathname] the temp directory written
|
|
73
|
+
def witness(session, name,
|
|
74
|
+
ignore_selectors: nil,
|
|
75
|
+
stabilize: nil,
|
|
76
|
+
skip_dom: false,
|
|
77
|
+
skip_element_map: false,
|
|
78
|
+
max_elements: nil,
|
|
79
|
+
project_root: nil)
|
|
80
|
+
browser = resolve_browser(session)
|
|
81
|
+
root = Pathname.new(project_root || Config.project_root)
|
|
82
|
+
config = Config.load(root)
|
|
83
|
+
|
|
84
|
+
selectors = ((config["ignoreSelectors"] || []) + (ignore_selectors || [])).uniq
|
|
85
|
+
stabilize = config["stabilize"] if stabilize.nil?
|
|
86
|
+
stabilize = true if stabilize.nil?
|
|
87
|
+
|
|
88
|
+
temp_dir = root.join(".testivai", "temp", name.to_s)
|
|
89
|
+
FileUtils.mkdir_p(temp_dir)
|
|
90
|
+
|
|
91
|
+
css = +""
|
|
92
|
+
css << STABILIZE_CSS if stabilize
|
|
93
|
+
css << hide_css(selectors) unless selectors.empty?
|
|
94
|
+
|
|
95
|
+
injected = false
|
|
96
|
+
unless css.empty?
|
|
97
|
+
injected = inject_css(browser, css)
|
|
98
|
+
wait_for_fonts(browser) if stabilize
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
begin
|
|
102
|
+
screenshot = capture_screenshot(browser)
|
|
103
|
+
ensure
|
|
104
|
+
remove_css(browser) if injected
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
File.binwrite(temp_dir.join("screenshot.png"), screenshot)
|
|
108
|
+
|
|
109
|
+
# DOM snapshot — best-effort. Losing it only costs the noise hint;
|
|
110
|
+
# it must never break the screenshot path.
|
|
111
|
+
unless skip_dom
|
|
112
|
+
begin
|
|
113
|
+
dom = browser.execute_script(DOM_SNAPSHOT_JS, selectors)
|
|
114
|
+
temp_dir.join("dom.html").write(dom) if dom.is_a?(String) && !dom.empty?
|
|
115
|
+
rescue StandardError # rubocop:disable Lint/SuppressedException
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Element map — same best-effort contract. Powers region→selector
|
|
120
|
+
# attribution, shift classification, and the style fingerprint.
|
|
121
|
+
unless skip_element_map
|
|
122
|
+
begin
|
|
123
|
+
expr = ElementMap.expression(max_elements || ElementMap::DEFAULT_MAX_ELEMENTS, selectors)
|
|
124
|
+
map = browser.execute_script(expr)
|
|
125
|
+
if map.is_a?(Array) && !map.empty?
|
|
126
|
+
temp_dir.join("elements.json").write(JSON.generate(map))
|
|
127
|
+
end
|
|
128
|
+
rescue StandardError # rubocop:disable Lint/SuppressedException
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
temp_dir
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Capybara sessions wrap the real driver; a bare Selenium driver is
|
|
136
|
+
# already what we want. Accept both so this works in RSpec/Capybara,
|
|
137
|
+
# Cucumber, or plain selenium-webdriver.
|
|
138
|
+
def resolve_browser(session)
|
|
139
|
+
return session.driver.browser if session.respond_to?(:driver) && session.driver.respond_to?(:browser)
|
|
140
|
+
return session.browser if session.respond_to?(:browser)
|
|
141
|
+
|
|
142
|
+
session
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def hide_css(selectors)
|
|
146
|
+
selectors.map { |s| "#{s} { visibility: hidden !important; }" }.join("\n")
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def inject_css(browser, css)
|
|
150
|
+
browser.execute_script(INJECT_STYLE_JS, STYLE_ID, css)
|
|
151
|
+
true
|
|
152
|
+
rescue StandardError
|
|
153
|
+
false
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def remove_css(browser)
|
|
157
|
+
browser.execute_script(REMOVE_STYLE_JS, STYLE_ID)
|
|
158
|
+
rescue StandardError # rubocop:disable Lint/SuppressedException
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def wait_for_fonts(browser, timeout: 10.0)
|
|
162
|
+
deadline = Time.now + timeout
|
|
163
|
+
loop do
|
|
164
|
+
return if browser.execute_script(FONTS_READY_JS)
|
|
165
|
+
return if Time.now > deadline
|
|
166
|
+
|
|
167
|
+
sleep 0.05
|
|
168
|
+
end
|
|
169
|
+
rescue StandardError # rubocop:disable Lint/SuppressedException
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Full page where the browser offers it, viewport otherwise.
|
|
173
|
+
#
|
|
174
|
+
# - Chromium: CDP Page.captureScreenshot with captureBeyondViewport,
|
|
175
|
+
# the same mechanism Playwright uses — a true full-page capture with
|
|
176
|
+
# no window resizing (resizing breaks 100vh layouts).
|
|
177
|
+
# - Firefox: selenium-webdriver exposes save_full_page_screenshot.
|
|
178
|
+
# - anything else: the plain viewport screenshot.
|
|
179
|
+
def capture_screenshot(browser)
|
|
180
|
+
if browser.respond_to?(:execute_cdp)
|
|
181
|
+
begin
|
|
182
|
+
result = browser.execute_cdp("Page.captureScreenshot",
|
|
183
|
+
"captureBeyondViewport" => true, "format" => "png")
|
|
184
|
+
data = result && (result["data"] || result[:data])
|
|
185
|
+
return Base64.decode64(data) if data
|
|
186
|
+
rescue StandardError # rubocop:disable Lint/SuppressedException
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
if browser.respond_to?(:full_screenshot_as)
|
|
191
|
+
begin
|
|
192
|
+
return browser.full_screenshot_as(:png)
|
|
193
|
+
rescue StandardError # rubocop:disable Lint/SuppressedException
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
browser.screenshot_as(:png)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Testivai
|
|
7
|
+
# Reads `.testivai/config.json`, the same file every other TestivAI adapter
|
|
8
|
+
# reads. Absent or malformed config falls back to defaults rather than
|
|
9
|
+
# raising: a broken config should not fail somebody's test suite.
|
|
10
|
+
module Config
|
|
11
|
+
DEFAULTS = {
|
|
12
|
+
"stabilize" => true,
|
|
13
|
+
"ignoreSelectors" => [],
|
|
14
|
+
"baselinesDir" => nil
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
# Walk up from `start` looking for a `.testivai/` directory, the way
|
|
18
|
+
# Bundler finds a Gemfile. Falls back to `start` so a first run in a
|
|
19
|
+
# fresh project still writes somewhere sensible.
|
|
20
|
+
def self.project_root(start = Dir.pwd)
|
|
21
|
+
current = Pathname.new(start).expand_path
|
|
22
|
+
current.ascend do |dir|
|
|
23
|
+
return dir if dir.join(".testivai").directory?
|
|
24
|
+
end
|
|
25
|
+
Pathname.new(start).expand_path
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.load(root)
|
|
29
|
+
path = Pathname.new(root).join(".testivai", "config.json")
|
|
30
|
+
return DEFAULTS.dup unless path.file?
|
|
31
|
+
|
|
32
|
+
DEFAULTS.merge(JSON.parse(path.read))
|
|
33
|
+
rescue JSON::ParserError, SystemCallError
|
|
34
|
+
DEFAULTS.dup
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AUTO-GENERATED — DO NOT EDIT.
|
|
3
|
+
*
|
|
4
|
+
* Canonical element-map collector, emitted from
|
|
5
|
+
* packages/witness/src/capture/element-map.ts
|
|
6
|
+
* by
|
|
7
|
+
* scripts/generate-element-map-asset.js
|
|
8
|
+
*
|
|
9
|
+
* Every TestivAI adapter injects this exact function so that element maps
|
|
10
|
+
* are identical across languages sharing one baseline directory. Edit the
|
|
11
|
+
* TypeScript source and re-run the generator; CI fails if this file is
|
|
12
|
+
* stale.
|
|
13
|
+
*
|
|
14
|
+
* Default element cap: 3000
|
|
15
|
+
*
|
|
16
|
+
* Call form (each adapter wraps it the same way):
|
|
17
|
+
* return (<this function>)(document, window, <maxElements>, <ignoreSelectors>);
|
|
18
|
+
*/
|
|
19
|
+
function collectElementMap(doc, win, maxElements, ignoreSelectors = []) {
|
|
20
|
+
var STYLE_PROPS = [
|
|
21
|
+
'color', 'background-color', 'background-image',
|
|
22
|
+
'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color',
|
|
23
|
+
'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width',
|
|
24
|
+
'border-top-style', 'border-bottom-style', 'border-radius',
|
|
25
|
+
'font-family', 'font-size', 'font-weight', 'font-style', 'line-height',
|
|
26
|
+
'text-align', 'text-transform', 'letter-spacing',
|
|
27
|
+
'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
|
|
28
|
+
'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
|
|
29
|
+
'display', 'opacity', 'visibility', 'box-shadow',
|
|
30
|
+
];
|
|
31
|
+
function fnv1a(str) {
|
|
32
|
+
var h = 0x811c9dc5;
|
|
33
|
+
for (var i = 0; i < str.length; i++) {
|
|
34
|
+
h ^= str.charCodeAt(i);
|
|
35
|
+
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
|
|
36
|
+
}
|
|
37
|
+
return ('0000000' + h.toString(16)).slice(-8);
|
|
38
|
+
}
|
|
39
|
+
function segment(el) {
|
|
40
|
+
var tag = el.tagName.toLowerCase();
|
|
41
|
+
var cls = '';
|
|
42
|
+
if (el.classList && el.classList.length > 0) {
|
|
43
|
+
cls = '.' + el.classList[0];
|
|
44
|
+
}
|
|
45
|
+
var parent = el.parentElement;
|
|
46
|
+
if (!parent)
|
|
47
|
+
return tag + cls;
|
|
48
|
+
var sameTag = 0;
|
|
49
|
+
var index = 0;
|
|
50
|
+
for (var i = 0; i < parent.children.length; i++) {
|
|
51
|
+
var sib = parent.children[i];
|
|
52
|
+
if (sib.tagName === el.tagName) {
|
|
53
|
+
sameTag++;
|
|
54
|
+
if (sib === el)
|
|
55
|
+
index = sameTag;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return sameTag > 1 ? tag + cls + ':nth-of-type(' + index + ')' : tag + cls;
|
|
59
|
+
}
|
|
60
|
+
function pathOf(el, stopAt) {
|
|
61
|
+
var parts = [];
|
|
62
|
+
var node = el;
|
|
63
|
+
while (node && node !== stopAt.parentElement) {
|
|
64
|
+
parts.unshift(segment(node));
|
|
65
|
+
node = node.parentElement;
|
|
66
|
+
}
|
|
67
|
+
return parts.join(' > ');
|
|
68
|
+
}
|
|
69
|
+
var dpr = win.devicePixelRatio || 1;
|
|
70
|
+
var scrollX = win.scrollX || 0;
|
|
71
|
+
var scrollY = win.scrollY || 0;
|
|
72
|
+
var out = [];
|
|
73
|
+
var body = doc.body;
|
|
74
|
+
if (!body)
|
|
75
|
+
return out;
|
|
76
|
+
var stack = [body];
|
|
77
|
+
while (stack.length > 0 && out.length < maxElements) {
|
|
78
|
+
var el = stack.pop();
|
|
79
|
+
// The consistency rule: elements covered by ignoreSelectors are
|
|
80
|
+
// excluded from pixels and the DOM snapshot — they must be excluded
|
|
81
|
+
// from the element map too (subtree included), or their dynamic
|
|
82
|
+
// styles would trip the style fingerprint they were meant to escape.
|
|
83
|
+
var ignored = false;
|
|
84
|
+
if (ignoreSelectors.length > 0 && typeof el.matches === 'function') {
|
|
85
|
+
for (var g = 0; g < ignoreSelectors.length; g++) {
|
|
86
|
+
try {
|
|
87
|
+
if (el.matches(ignoreSelectors[g])) {
|
|
88
|
+
ignored = true;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
// invalid selector — never breaks the walk
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (ignored)
|
|
98
|
+
continue; // skip element AND subtree
|
|
99
|
+
var rect = el.getBoundingClientRect();
|
|
100
|
+
if (rect.width >= 4 && rect.height >= 4) {
|
|
101
|
+
var styleParts = [];
|
|
102
|
+
var hidden = false;
|
|
103
|
+
try {
|
|
104
|
+
var cs = win.getComputedStyle(el);
|
|
105
|
+
for (var p = 0; p < STYLE_PROPS.length; p++) {
|
|
106
|
+
var value = cs.getPropertyValue(STYLE_PROPS[p]);
|
|
107
|
+
if (STYLE_PROPS[p] === 'visibility' && value === 'hidden')
|
|
108
|
+
hidden = true;
|
|
109
|
+
styleParts.push(STYLE_PROPS[p] + ':' + value);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
// styleHash stays a digest of the empty string — still deterministic
|
|
114
|
+
}
|
|
115
|
+
// visibility:hidden elements paint no pixels, so their style changes
|
|
116
|
+
// can never explain a pixel diff — keep them out of the map. Their
|
|
117
|
+
// CHILDREN may override visibility, so the subtree still walks.
|
|
118
|
+
if (!hidden) {
|
|
119
|
+
out.push({
|
|
120
|
+
path: pathOf(el, body),
|
|
121
|
+
x: Math.round((rect.x + scrollX) * dpr),
|
|
122
|
+
y: Math.round((rect.y + scrollY) * dpr),
|
|
123
|
+
width: Math.round(rect.width * dpr),
|
|
124
|
+
height: Math.round(rect.height * dpr),
|
|
125
|
+
styleHash: fnv1a(styleParts.join(';')),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Push children in reverse so the walk stays document-ordered
|
|
130
|
+
for (var c = el.children.length - 1; c >= 0; c--) {
|
|
131
|
+
stack.push(el.children[c]);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "pathname"
|
|
5
|
+
|
|
6
|
+
module Testivai
|
|
7
|
+
# Loads the canonical element-map collector and builds the expression the
|
|
8
|
+
# adapter injects.
|
|
9
|
+
#
|
|
10
|
+
# `element_map.js` is GENERATED from
|
|
11
|
+
# packages/witness/src/capture/element-map.ts by
|
|
12
|
+
# scripts/generate-element-map-asset.js, and CI fails when it is stale.
|
|
13
|
+
# Every adapter — TypeScript, Python, Java, Ruby — injects that identical
|
|
14
|
+
# function. That matters because they all write into one shared
|
|
15
|
+
# `.testivai/baselines/` directory: two languages producing subtly
|
|
16
|
+
# different maps for the same page would show up as a phantom regression.
|
|
17
|
+
module ElementMap
|
|
18
|
+
# Matches DEFAULT_MAX_ELEMENTS in the TypeScript source.
|
|
19
|
+
DEFAULT_MAX_ELEMENTS = 3000
|
|
20
|
+
|
|
21
|
+
ASSET = Pathname.new(__dir__).join("element_map.js")
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# Collector source with the generated banner stripped — that comment
|
|
25
|
+
# helps a reader but is pure weight on the wire, and this string ships
|
|
26
|
+
# to the browser on every capture.
|
|
27
|
+
def source
|
|
28
|
+
@source ||= begin
|
|
29
|
+
# Explicit UTF-8: the generated asset contains non-ASCII characters,
|
|
30
|
+
# and Pathname#read would otherwise use the default external
|
|
31
|
+
# encoding (US-ASCII on some systems), making `strip` raise.
|
|
32
|
+
raw = ASSET.read(encoding: "UTF-8")
|
|
33
|
+
start = raw.index("function collectElementMap")
|
|
34
|
+
if start.nil?
|
|
35
|
+
raise "element_map.js is malformed: collectElementMap not found. " \
|
|
36
|
+
"Regenerate with scripts/generate-element-map-asset.js"
|
|
37
|
+
end
|
|
38
|
+
raw[start..].strip
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Wrapped exactly as `buildElementMapExpression` does on the TypeScript
|
|
43
|
+
# side. WebDriver needs the explicit `return`.
|
|
44
|
+
def expression(max_elements, ignore_selectors)
|
|
45
|
+
"return (#{source})(document, window, #{max_elements.to_i}, " \
|
|
46
|
+
"#{JSON.generate(Array(ignore_selectors))});"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/testivai.rb
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "testivai/version"
|
|
4
|
+
require_relative "testivai/config"
|
|
5
|
+
require_relative "testivai/element_map"
|
|
6
|
+
require_relative "testivai/capture"
|
|
7
|
+
|
|
8
|
+
# TestivAI — local-first visual regression testing for Ruby test suites.
|
|
9
|
+
#
|
|
10
|
+
# require "testivai"
|
|
11
|
+
#
|
|
12
|
+
# it "renders the homepage" do
|
|
13
|
+
# visit "/"
|
|
14
|
+
# Testivai.witness(page, "homepage")
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# Then compare and build the report:
|
|
18
|
+
#
|
|
19
|
+
# npx testivai report
|
|
20
|
+
module Testivai
|
|
21
|
+
# Capture a snapshot. See Testivai::Capture.witness for options.
|
|
22
|
+
def self.witness(session, name, **options)
|
|
23
|
+
Capture.witness(session, name, **options)
|
|
24
|
+
end
|
|
25
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: testivai
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Budi Sugianto
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-07-28 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: |
|
|
14
|
+
Capture screenshots, DOM snapshots, and element maps from Capybara or
|
|
15
|
+
Selenium, diff them against baselines committed to your repository, and
|
|
16
|
+
get a self-contained HTML report that explains what changed and why.
|
|
17
|
+
No account, no API key, nothing uploaded.
|
|
18
|
+
email:
|
|
19
|
+
- testivai.app@gmail.com
|
|
20
|
+
executables: []
|
|
21
|
+
extensions: []
|
|
22
|
+
extra_rdoc_files: []
|
|
23
|
+
files:
|
|
24
|
+
- LICENSE
|
|
25
|
+
- README.md
|
|
26
|
+
- lib/testivai.rb
|
|
27
|
+
- lib/testivai/capture.rb
|
|
28
|
+
- lib/testivai/config.rb
|
|
29
|
+
- lib/testivai/element_map.js
|
|
30
|
+
- lib/testivai/element_map.rb
|
|
31
|
+
- lib/testivai/version.rb
|
|
32
|
+
homepage: https://testiv.ai
|
|
33
|
+
licenses:
|
|
34
|
+
- MIT
|
|
35
|
+
metadata:
|
|
36
|
+
homepage_uri: https://testiv.ai
|
|
37
|
+
source_code_uri: https://github.com/mcbuddy/testivai-oss
|
|
38
|
+
documentation_uri: https://testiv.ai/docs/frameworks/ruby/
|
|
39
|
+
changelog_uri: https://github.com/mcbuddy/testivai-oss/releases
|
|
40
|
+
post_install_message:
|
|
41
|
+
rdoc_options: []
|
|
42
|
+
require_paths:
|
|
43
|
+
- lib
|
|
44
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: 2.7.0
|
|
49
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
requirements: []
|
|
55
|
+
rubygems_version: 3.4.19
|
|
56
|
+
signing_key:
|
|
57
|
+
specification_version: 4
|
|
58
|
+
summary: Local-first visual regression testing for Ruby test suites
|
|
59
|
+
test_files: []
|