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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 65bd512ddfb0a96d7d0be93240ed9b5f62ffe6a22069062ec140e3a8270bfd14
4
- data.tar.gz: 9dbc5d2cfc6104649f47147e60d1dc7669e7a12fbf8ca53a8978f2c044e3f38c
3
+ metadata.gz: 31e449c5ec19cb4652d9552a3f56c816e31b99646cde457488f2f009c5355092
4
+ data.tar.gz: bde5b4afcbe120a2a19e3d93f6816eef02ae71c77541724b33c980de017371c5
5
5
  SHA512:
6
- metadata.gz: d58f7f2eac3856e27e0f446503cf2c43adeb68c623fe421277a7df87df12896216518a4892c9c07f6c3a1f3b075081ec0d49b4e097ace983785f7c5cce23e428
7
- data.tar.gz: 3101cc06177e403c240f47380f3e0785e941dc7acabf9a0170bf656435a4a4b361a3236e5c41dc50c2c250d47a5b68ebee99b02ed237ed3500fe377412653b8d
6
+ metadata.gz: b192e945ee1181628229feab6bf3d27218b08b9d76489047e4793f0dfda96106985bd35585fc98d775df9d3109f5ec8f981a06b08d3a9195ead6869ff93afb58
7
+ data.tar.gz: 25c9bbe89b6e7886e73870f68dbf030525dd0d0399de79e680197e095288bfe2fc2937935d1777e05794122771d0929be2f2081e2149e70ed4ac5217356e2750
@@ -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
- wait_for_fonts(browser) if stabilize
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Testivai
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.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-28 00:00:00.000000000 Z
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: