testivai 0.1.0 → 0.2.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 +4 -4
- data/lib/testivai/capture.rb +15 -1
- data/lib/testivai/settle.js +70 -0
- data/lib/testivai/settle.rb +73 -0
- data/lib/testivai/shard.rb +70 -0
- data/lib/testivai/version.rb +1 -1
- data/lib/testivai.rb +2 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 31e449c5ec19cb4652d9552a3f56c816e31b99646cde457488f2f009c5355092
|
|
4
|
+
data.tar.gz: bde5b4afcbe120a2a19e3d93f6816eef02ae71c77541724b33c980de017371c5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b192e945ee1181628229feab6bf3d27218b08b9d76489047e4793f0dfda96106985bd35585fc98d775df9d3109f5ec8f981a06b08d3a9195ead6869ff93afb58
|
|
7
|
+
data.tar.gz: 25c9bbe89b6e7886e73870f68dbf030525dd0d0399de79e680197e095288bfe2fc2937935d1777e05794122771d0929be2f2081e2149e70ed4ac5217356e2750
|
data/lib/testivai/capture.rb
CHANGED
|
@@ -7,6 +7,8 @@ require "pathname"
|
|
|
7
7
|
|
|
8
8
|
require_relative "config"
|
|
9
9
|
require_relative "element_map"
|
|
10
|
+
require_relative "shard"
|
|
11
|
+
require_relative "settle"
|
|
10
12
|
|
|
11
13
|
module Testivai
|
|
12
14
|
# Capture half of the pipeline: writes `.testivai/temp/<name>/` with the
|
|
@@ -95,13 +97,19 @@ module Testivai
|
|
|
95
97
|
injected = false
|
|
96
98
|
unless css.empty?
|
|
97
99
|
injected = inject_css(browser, css)
|
|
98
|
-
|
|
100
|
+
if stabilize
|
|
101
|
+
wait_for_fonts(browser)
|
|
102
|
+
# Then wait for the page itself to stop changing — images finished,
|
|
103
|
+
# DOM quiet. The load question the DOM/style layer cannot answer.
|
|
104
|
+
Settle.wait_for(browser)
|
|
105
|
+
end
|
|
99
106
|
end
|
|
100
107
|
|
|
101
108
|
begin
|
|
102
109
|
screenshot = capture_screenshot(browser)
|
|
103
110
|
ensure
|
|
104
111
|
remove_css(browser) if injected
|
|
112
|
+
Settle.stop(browser) if stabilize
|
|
105
113
|
end
|
|
106
114
|
|
|
107
115
|
File.binwrite(temp_dir.join("screenshot.png"), screenshot)
|
|
@@ -129,6 +137,12 @@ module Testivai
|
|
|
129
137
|
end
|
|
130
138
|
end
|
|
131
139
|
|
|
140
|
+
# Shard manifest — refreshed per capture because RSpec offers the gem no
|
|
141
|
+
# end-of-run hook. This is what lets a Ruby suite join the same sharded
|
|
142
|
+
# CI flow as every other adapter.
|
|
143
|
+
shard = Shard.from_env
|
|
144
|
+
Shard.write_manifest(root.join(".testivai", "temp"), shard) if shard
|
|
145
|
+
|
|
132
146
|
temp_dir
|
|
133
147
|
end
|
|
134
148
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AUTO-GENERATED — DO NOT EDIT.
|
|
3
|
+
*
|
|
4
|
+
* Page-settled probe, emitted from
|
|
5
|
+
* packages/witness/src/capture/settle.ts
|
|
6
|
+
* by
|
|
7
|
+
* scripts/generate-element-map-asset.js
|
|
8
|
+
*
|
|
9
|
+
* Polled by each adapter from its host language until `settled` is true or a
|
|
10
|
+
* timeout elapses. Deliberately not network idle — Playwright's own docs mark
|
|
11
|
+
* that DISCOURAGED for testing, and it is the wrong signal for a visual
|
|
12
|
+
* snapshot anyway.
|
|
13
|
+
*
|
|
14
|
+
* Default DOM-quiet threshold: 150ms
|
|
15
|
+
*
|
|
16
|
+
* Call form:
|
|
17
|
+
* return (<this function>)(document, window, <quietMs>);
|
|
18
|
+
*/
|
|
19
|
+
function settleProbe(doc, win, quietMs) {
|
|
20
|
+
var KEY = '__testivaiSettleState';
|
|
21
|
+
var state = win[KEY];
|
|
22
|
+
if (!state) {
|
|
23
|
+
state = { last: Date.now() };
|
|
24
|
+
try {
|
|
25
|
+
if (typeof win.MutationObserver === 'function') {
|
|
26
|
+
var observer = new win.MutationObserver(function () {
|
|
27
|
+
win[KEY].last = Date.now();
|
|
28
|
+
});
|
|
29
|
+
observer.observe(doc.documentElement, {
|
|
30
|
+
subtree: true,
|
|
31
|
+
childList: true,
|
|
32
|
+
attributes: true,
|
|
33
|
+
characterData: true,
|
|
34
|
+
});
|
|
35
|
+
state.observer = observer;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
// No MutationObserver (or a hostile document): quietness degrades to
|
|
40
|
+
// "always quiet" rather than blocking the capture.
|
|
41
|
+
}
|
|
42
|
+
win[KEY] = state;
|
|
43
|
+
}
|
|
44
|
+
var imagesPending = 0;
|
|
45
|
+
try {
|
|
46
|
+
for (var i = 0; i < doc.images.length; i++) {
|
|
47
|
+
if (!doc.images[i].complete)
|
|
48
|
+
imagesPending++;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
imagesPending = 0;
|
|
53
|
+
}
|
|
54
|
+
var fontsPending = false;
|
|
55
|
+
try {
|
|
56
|
+
fontsPending = !!(doc.fonts && doc.fonts.status && doc.fonts.status !== 'loaded');
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
fontsPending = false;
|
|
60
|
+
}
|
|
61
|
+
var ready = doc.readyState === 'complete';
|
|
62
|
+
var quietFor = Date.now() - state.last;
|
|
63
|
+
return {
|
|
64
|
+
ready: ready,
|
|
65
|
+
imagesPending: imagesPending,
|
|
66
|
+
fontsPending: fontsPending,
|
|
67
|
+
quietFor: quietFor,
|
|
68
|
+
settled: ready && imagesPending === 0 && !fontsPending && quietFor >= quietMs,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Testivai
|
|
6
|
+
# Page-settled probe: "has this page stopped changing?"
|
|
7
|
+
#
|
|
8
|
+
# `settle.js` is GENERATED from packages/witness/src/capture/settle.ts, so
|
|
9
|
+
# every language polls the identical predicate.
|
|
10
|
+
#
|
|
11
|
+
# Deliberately NOT network idle. Playwright's own docs mark that DISCOURAGED
|
|
12
|
+
# for testing, and it is the wrong signal for a visual snapshot anyway — a
|
|
13
|
+
# page with analytics beacons never goes quiet, while a network-idle page can
|
|
14
|
+
# still be animating. What matters is whether the rendered page settled.
|
|
15
|
+
module Settle
|
|
16
|
+
DEFAULT_QUIET_MS = 150
|
|
17
|
+
DEFAULT_TIMEOUT = 5.0
|
|
18
|
+
ASSET = Pathname.new(__dir__).join("settle.js")
|
|
19
|
+
|
|
20
|
+
STOP_JS = <<~JS
|
|
21
|
+
try { var s = window.__testivaiSettleState;
|
|
22
|
+
if (s && s.observer && s.observer.disconnect) s.observer.disconnect(); } catch (e) {}
|
|
23
|
+
try { delete window.__testivaiSettleState; } catch (e) {}
|
|
24
|
+
JS
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def source
|
|
28
|
+
@source ||= begin
|
|
29
|
+
# Explicit UTF-8: the generated asset is non-ASCII and Pathname#read
|
|
30
|
+
# would otherwise use the default external encoding.
|
|
31
|
+
raw = ASSET.read(encoding: "UTF-8")
|
|
32
|
+
start = raw.index("function settleProbe")
|
|
33
|
+
raise "settle.js is malformed: settleProbe not found" if start.nil?
|
|
34
|
+
|
|
35
|
+
raw[start..].strip
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def expression(quiet_ms = DEFAULT_QUIET_MS)
|
|
40
|
+
"return (#{source})(document, window, #{quiet_ms.to_i});"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Poll until settled or the deadline passes. Always bounded: a page that
|
|
44
|
+
# never settles yields a capture, not a hang. A browser that cannot
|
|
45
|
+
# evaluate the probe returns something unusable, so we proceed at once
|
|
46
|
+
# rather than paying the whole timeout on every capture.
|
|
47
|
+
def wait_for(browser, quiet_ms: DEFAULT_QUIET_MS, timeout: DEFAULT_TIMEOUT)
|
|
48
|
+
expr = expression(quiet_ms)
|
|
49
|
+
deadline = Time.now + timeout
|
|
50
|
+
loop do
|
|
51
|
+
state = begin
|
|
52
|
+
browser.execute_script(expr)
|
|
53
|
+
rescue StandardError
|
|
54
|
+
return
|
|
55
|
+
end
|
|
56
|
+
return unless state.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
settled = state["settled"]
|
|
59
|
+
return unless [true, false].include?(settled)
|
|
60
|
+
return if settled || Time.now >= deadline
|
|
61
|
+
|
|
62
|
+
sleep 0.05
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Detach the observer so it does not linger on the page under test.
|
|
67
|
+
def stop(browser)
|
|
68
|
+
browser.execute_script(STOP_JS)
|
|
69
|
+
rescue StandardError # rubocop:disable Lint/SuppressedException
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module Testivai
|
|
8
|
+
# Shard participation for parallel and sharded runs.
|
|
9
|
+
#
|
|
10
|
+
# Playwright can tell its reporter `--shard=i/N`; RSpec cannot. So the
|
|
11
|
+
# mechanism is the environment, not a framework API, and every TestivAI
|
|
12
|
+
# adapter honours the same two variables:
|
|
13
|
+
#
|
|
14
|
+
# TESTIVAI_CAPTURE_ONLY=1 capture, do not compare
|
|
15
|
+
# TESTIVAI_SHARD=3/8 this process is shard 3 of 8
|
|
16
|
+
#
|
|
17
|
+
# That is what keeps a Ruby suite a first-class citizen of the same sharded
|
|
18
|
+
# CI flow as Playwright rather than a second-class one.
|
|
19
|
+
module Shard
|
|
20
|
+
PATTERN = %r{\A\s*(\d+)\s*(?:/|of)\s*(\d+)\s*\z}i
|
|
21
|
+
MANIFEST = "testivai-shard.json"
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# Parse TESTIVAI_SHARD; nil when unset or malformed.
|
|
26
|
+
def from_env(env = ENV)
|
|
27
|
+
parse(env["TESTIVAI_SHARD"])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def parse(raw)
|
|
31
|
+
m = PATTERN.match(raw.to_s)
|
|
32
|
+
return nil unless m
|
|
33
|
+
|
|
34
|
+
current = m[1].to_i
|
|
35
|
+
total = m[2].to_i
|
|
36
|
+
return nil if total < 1 || current < 1 || current > total
|
|
37
|
+
|
|
38
|
+
{ current: current, total: total }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def capture_only?(env = ENV)
|
|
42
|
+
raw = env["TESTIVAI_CAPTURE_ONLY"].to_s
|
|
43
|
+
return !%w[0 false].include?(raw.downcase) unless raw.empty?
|
|
44
|
+
|
|
45
|
+
s = from_env(env)
|
|
46
|
+
!s.nil? && s[:total] > 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Write (or refresh) the manifest merge-captures reads.
|
|
50
|
+
#
|
|
51
|
+
# RSpec gives the gem no end-of-run hook, so this is refreshed on every
|
|
52
|
+
# capture and `complete` stays false — it records that the shard
|
|
53
|
+
# participated, not that it finished.
|
|
54
|
+
def write_manifest(temp_dir, shard, complete: false)
|
|
55
|
+
dir = Pathname.new(temp_dir)
|
|
56
|
+
dir.mkpath
|
|
57
|
+
captures = dir.children.select(&:directory?).map { |p| p.basename.to_s }.sort
|
|
58
|
+
dir.join(MANIFEST).write(
|
|
59
|
+
JSON.pretty_generate(
|
|
60
|
+
"shard" => { "current" => shard[:current], "total" => shard[:total] },
|
|
61
|
+
"captures" => captures,
|
|
62
|
+
"complete" => complete,
|
|
63
|
+
"timestamp" => Time.now.utc.iso8601
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
rescue StandardError # rubocop:disable Lint/SuppressedException
|
|
67
|
+
# Never fail a capture over a manifest.
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/testivai/version.rb
CHANGED
data/lib/testivai.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require_relative "testivai/version"
|
|
4
4
|
require_relative "testivai/config"
|
|
5
5
|
require_relative "testivai/element_map"
|
|
6
|
+
require_relative "testivai/shard"
|
|
7
|
+
require_relative "testivai/settle"
|
|
6
8
|
require_relative "testivai/capture"
|
|
7
9
|
|
|
8
10
|
# TestivAI — local-first visual regression testing for Ruby test suites.
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: testivai
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Budi Sugianto
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-31 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: |
|
|
14
14
|
Capture screenshots, DOM snapshots, and element maps from Capybara or
|
|
@@ -28,6 +28,9 @@ files:
|
|
|
28
28
|
- lib/testivai/config.rb
|
|
29
29
|
- lib/testivai/element_map.js
|
|
30
30
|
- lib/testivai/element_map.rb
|
|
31
|
+
- lib/testivai/settle.js
|
|
32
|
+
- lib/testivai/settle.rb
|
|
33
|
+
- lib/testivai/shard.rb
|
|
31
34
|
- lib/testivai/version.rb
|
|
32
35
|
homepage: https://testiv.ai
|
|
33
36
|
licenses:
|