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 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Testivai
4
+ VERSION = "0.1.0"
5
+ 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: []