syrma 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.
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syrma
4
+ module Instrumentation
5
+ Capture = Struct.new(:backend, :windows, :on_open, keyword_init: true)
6
+
7
+ module WindowCapture
8
+ attr_reader :testing_root, :testing_clear, :testing_frame
9
+ attr_accessor :testing_lazy_raster
10
+
11
+ def on_frame(&callback)
12
+ @testing_on_frame = callback
13
+ super do |element, clear|
14
+ @testing_root = element
15
+ @testing_clear = clear
16
+ @testing_frame = (@testing_frame || 0) + 1
17
+ @testing_on_frame&.call(element, clear)
18
+ end
19
+ end
20
+
21
+ def input(event)
22
+ testing_recorder&.record(event)
23
+ super
24
+ end
25
+
26
+ def testing_recorder = @testing_recorder
27
+
28
+ def testing_recorder=(recorder)
29
+ @testing_recorder = recorder
30
+ end
31
+
32
+ def render(element, present: true, **options)
33
+ super(element, present: present && !testing_lazy_raster, **options)
34
+ end
35
+ end
36
+
37
+ module BackendOverride
38
+ def open_window(backend: :headless, **options)
39
+ capture = Thread.current[:syrma_window_capture]
40
+ return super unless capture
41
+
42
+ window = if capture.backend
43
+ allowed = options.slice(:width, :height, :title, :scale_factor, :input, :output, :keymap, :clock)
44
+ super(backend: capture.backend, **allowed)
45
+ else
46
+ super
47
+ end
48
+ capture.windows << window
49
+ capture.on_open&.call(window)
50
+ window
51
+ end
52
+ end
53
+
54
+ class << self
55
+ def install!
56
+ return if @installed
57
+
58
+ Zaniah::Platform.singleton_class.prepend(BackendOverride)
59
+ @installed = true
60
+ end
61
+
62
+ def capture_windows(backend: :headless, on_open: nil)
63
+ previous = Thread.current[:syrma_window_capture]
64
+ capture = Capture.new(backend: backend, windows: [], on_open: on_open)
65
+ Thread.current[:syrma_window_capture] = capture
66
+ yield
67
+ capture.windows
68
+ ensure
69
+ Thread.current[:syrma_window_capture] = previous
70
+ end
71
+
72
+ def capture(window, lazy_raster:)
73
+ window.extend(WindowCapture) unless window.is_a?(WindowCapture)
74
+ window.testing_lazy_raster = lazy_raster
75
+ window.on_frame
76
+ window
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syrma
4
+ module Internals
5
+ module_function
6
+
7
+ def style(element) = element.instance_variable_get(:@style) || {}
8
+ def background(element) = element.instance_variable_get(:@background)
9
+ def border_color(element) = element.instance_variable_get(:@border_color)
10
+ def radius(element) = element.instance_variable_get(:@radius)
11
+ def tooltip(element) = element.instance_variable_get(:@tooltip)
12
+ def context_menu(element) = element.instance_variable_get(:@context_menu)
13
+ def key(element) = element.instance_variable_get(:@key)
14
+ def shown_tooltip(window)
15
+ tip = window.instance_variable_get(:@tooltip)
16
+ tip && tip[:shown] ? tip[:text] : nil
17
+ end
18
+
19
+ def bundled_font_path
20
+ _, entry = $LOAD_PATH.resolve_feature_path("zaniah")
21
+ File.expand_path("../assets/fonts/Abel-Regular.ttf", File.dirname(entry))
22
+ end
23
+
24
+ def executor_idle?(executor) = executor.instance_variable_get(:@foreground).empty?
25
+ end
26
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../syrma"
4
+
5
+ module Syrma
6
+ module Minitest
7
+ def zaniah_session(**options, &mount)
8
+ (@zaniah_sessions ||= []) << Session.new(**options, &mount)
9
+ @zaniah_sessions.last
10
+ end
11
+
12
+ def ui = @zaniah_sessions&.last || raise(Error, "call zaniah_session first")
13
+
14
+ def assert_ui_text(expected, session: ui, timeout: session.timeout)
15
+ ui_eventually(session, timeout, -> { "Expected text #{expected.inspect} to appear (current: #{session.texts.inspect})" }) do
16
+ session.texts.any? { |text| text_match?(text, expected) }
17
+ end
18
+ end
19
+
20
+ def refute_ui_text(unexpected, session: ui, timeout: session.timeout)
21
+ ui_eventually(session, timeout, -> { "Expected text #{unexpected.inspect} to disappear" }) do
22
+ session.texts.none? { |text| text_match?(text, unexpected) }
23
+ end
24
+ end
25
+
26
+ def assert_element(target = nil, count: nil, session: ui, **criteria)
27
+ target = criteria unless criteria.empty?
28
+ locator = locator_for(target, session)
29
+ if count
30
+ ui_eventually(session, session.timeout, -> { "Expected #{count} matches for #{locator} (current: #{locator.count})" }) do
31
+ locator.count == count
32
+ end
33
+ else
34
+ ui_eventually(session, session.timeout, -> { "Could not find #{locator}" }) { locator.exists? }
35
+ end
36
+ end
37
+
38
+ def refute_element(target = nil, session: ui, **criteria)
39
+ target = criteria unless criteria.empty?
40
+ locator = locator_for(target, session)
41
+ ui_eventually(session, session.timeout, -> { "Expected #{locator} to disappear" }) { !locator.exists? }
42
+ end
43
+
44
+ def assert_visible(target = nil, session: ui, **criteria)
45
+ target = criteria unless criteria.empty?
46
+ locator = locator_for(target, session)
47
+ ui_eventually(session, session.timeout, -> { "Expected #{locator} to be visible" }) { locator.visible? }
48
+ end
49
+
50
+ def assert_hidden(target = nil, session: ui, **criteria)
51
+ target = criteria unless criteria.empty?
52
+ locator = locator_for(target, session)
53
+ ui_eventually(session, session.timeout, -> { "Expected #{locator} to be hidden" }) { !locator.visible? }
54
+ end
55
+
56
+ def assert_clickable(target = nil, session: ui, **criteria)
57
+ target = criteria unless criteria.empty?
58
+ locator = locator_for(target, session)
59
+ reason = nil
60
+ ui_eventually(session, session.timeout, -> { "#{locator} is not clickable: #{reason}" }) do
61
+ node = locator.resolve
62
+ session.driver.__send__(:check_actionable, session.tree, node, :mouse_down, :left)
63
+ rescue ElementNotFound, NotActionable => error
64
+ reason = error.message
65
+ false
66
+ end
67
+ end
68
+
69
+ def assert_bg(target, color, session: ui)
70
+ locator = locator_for(target, session)
71
+ ui_eventually(session, session.timeout, -> { "Expected #{locator} background to be #{color.inspect}" }) do
72
+ locator.resolve_all.any? { |node| Criteria.new(bg: color).match?(node) }
73
+ end
74
+ end
75
+
76
+ def assert_pixel(x, y, color, threshold: 2, session: ui)
77
+ expected = Zaniah::Color.parse(color).to_a.map { |channel| (channel * 255).round }
78
+ actual = nil
79
+ ui_eventually(session, session.timeout, -> { "Expected pixel (#{x}, #{y}) to be #{color.inspect} (current: #{actual.inspect})" }) do
80
+ actual = session.pixel(x, y)
81
+ actual.zip(expected).all? { |left, right| (left - right).abs <= threshold }
82
+ end
83
+ end
84
+
85
+ def assert_tooltip(expected, session: ui)
86
+ ui_eventually(session, session.timeout, -> { "Expected tooltip #{expected.inspect} (current: #{session.tooltip.inspect})" }) do
87
+ text_match?(session.tooltip, expected)
88
+ end
89
+ end
90
+
91
+ def assert_menu_items(labels, session: ui)
92
+ ui_eventually(session, session.timeout, -> { "Expected menu items #{labels.inspect} (current: #{session.menu.items.inspect})" }) do
93
+ session.menu.items == labels
94
+ end
95
+ end
96
+
97
+ def assert_screenshot(name, session: ui, **options)
98
+ warn "syrma: text: :none does not render text in images" if session.text_mode == :none
99
+ store = Snapshots::Store.new
100
+ path = snapshot_path(store, name, "png")
101
+ status = Visual.check_screenshot(session, store: store, path: path,
102
+ artifacts: Diagnostics.dir_for(self.class.name, self.name), **options)
103
+ warn_snapshot(status, path)
104
+ pass
105
+ rescue SnapshotMismatch, SnapshotMissing => error
106
+ flunk error.message
107
+ end
108
+
109
+ def assert_tree_snapshot(name, root: nil, attributes: TreeFormat::DEFAULT, session: ui)
110
+ node = root ? (root.is_a?(Locator) ? root.resolve : root) : session.tree.root
111
+ assert_text_snapshot(name, "tree.txt", TreeFormat.dump(node, attributes: attributes))
112
+ end
113
+
114
+ def assert_terminal_snapshot(name, colors: false, session: ui)
115
+ assert_text_snapshot(name, "term.txt", session.terminal_lines(colors: colors).join("\n") + "\n")
116
+ end
117
+
118
+ def assert_no_text_overlap(session: ui)
119
+ overlaps = TerminalFormat.overlaps(session.tree.text_runs)
120
+ assert_empty overlaps, "Terminal text overlaps: #{overlaps.map { |a, b, row| "#{a.inspect} / #{b.inspect} (row #{row})" }.join(', ')}"
121
+ end
122
+
123
+ def after_teardown
124
+ unless passed? || skipped?
125
+ @zaniah_sessions&.each_with_index do |session, index|
126
+ suffix = index.zero? ? "" : "-#{index}"
127
+ Diagnostics.write(session, Diagnostics.dir_for(self.class.name, "#{name}#{suffix}"),
128
+ message: failures.map(&:message).join("\n\n"))
129
+ end
130
+ end
131
+ @zaniah_sessions&.each(&:close)
132
+ @zaniah_sessions = nil
133
+ super
134
+ end
135
+
136
+ private
137
+
138
+ def locator_for(target, session)
139
+ return session.find(**target) if target.is_a?(Hash)
140
+
141
+ target
142
+ end
143
+
144
+ def text_match?(actual, expected)
145
+ return false if actual.nil?
146
+
147
+ expected.is_a?(Regexp) ? expected.match?(actual) : actual == expected
148
+ end
149
+
150
+ def ui_eventually(session, timeout, message, &condition)
151
+ session.wait_for(message, timeout: timeout, &condition)
152
+ pass
153
+ rescue WaitTimeout => error
154
+ flunk error.message
155
+ end
156
+
157
+ def snapshot_path(store, name, extension)
158
+ test_file = self.class.instance_method(self.name.to_sym).source_location&.first || "unknown"
159
+ store.path(test_file: test_file, test_class: self.class.name, name: name, ext: extension)
160
+ end
161
+
162
+ def assert_text_snapshot(name, extension, actual)
163
+ store = Snapshots::Store.new
164
+ path = snapshot_path(store, name, extension)
165
+ if !File.exist?(path) || store.update?
166
+ existed = File.exist?(path)
167
+ flunk "Snapshot not found: #{path}" if !File.exist?(path) && store.ci? && !store.update?
168
+
169
+ store.write(path, actual)
170
+ warn_snapshot(existed ? :updated : :created, path)
171
+ return pass
172
+ end
173
+ assert_equal File.read(path, encoding: "UTF-8"), actual, "Snapshot mismatch: #{path}"
174
+ end
175
+
176
+ def warn_snapshot(status, path)
177
+ return if status == :matched
178
+
179
+ warn "syrma: #{status == :created ? 'created' : 'updated'} snapshot: #{path}"
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syrma
4
+ class PopupDriver
5
+ def initialize(driver) = @driver = driver
6
+ def open? = !@driver.tree.menu.nil?
7
+ def items = @driver.tree.menu || []
8
+ def selected = open? ? items[@driver.tree.menu_index] : nil
9
+ def dismiss = @driver.press("esc")
10
+
11
+ def select(label, via: :keyboard)
12
+ index = items.index(label) or raise Error, "Menu item not found: #{label.inspect} (#{items.inspect})"
13
+ unless @driver.window.popup.enabled[index]
14
+ raise NotActionable.new(:disabled, "Menu item #{label.inspect} is disabled")
15
+ end
16
+
17
+ via == :mouse ? select_by_mouse(index) : select_by_keyboard(index)
18
+ end
19
+
20
+ private
21
+
22
+ def select_by_keyboard(index)
23
+ items.length.times do
24
+ break if @driver.tree.menu_index == index
25
+
26
+ @driver.press("down")
27
+ end
28
+ @driver.press("enter")
29
+ end
30
+
31
+ def select_by_mouse(index)
32
+ bounds = @driver.window.popup.bounds
33
+ y = bounds.y + bounds.height.fdiv(items.length) * (index + 0.5)
34
+ @driver.mouse_down(Zaniah::Point.new(bounds.x + bounds.width / 2.0, y))
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syrma
4
+ class Criteria
5
+ KEYS = %i[test_id key text has_text type clickable handler tooltip visible bg].freeze
6
+
7
+ def initialize(conditions, predicate = nil)
8
+ unknown = conditions.keys - KEYS
9
+ raise ArgumentError, "Unknown search criteria: #{unknown.join(', ')}" unless unknown.empty?
10
+
11
+ @conditions = conditions.freeze
12
+ @predicate = predicate
13
+ end
14
+
15
+ def match?(node)
16
+ @conditions.all? { |name, expected| __send__(:"match_#{name}", node, expected) } &&
17
+ (@predicate.nil? || @predicate.call(node))
18
+ end
19
+
20
+ def to_s = @conditions.map { |key, value| "#{key}: #{value.inspect}" }.join(", ") + (@predicate ? " + block" : "")
21
+
22
+ private
23
+
24
+ def text_match(actual, expected)
25
+ return false if actual.nil?
26
+
27
+ expected.is_a?(Regexp) ? expected.match?(actual) : actual == expected
28
+ end
29
+
30
+ def match_test_id(node, value) = node.test_id == value.to_s
31
+ def match_key(node, value) = node.key == value
32
+ def match_text(node, value) = text_match(node.text, value)
33
+ def match_has_text(node, value) = value.is_a?(Regexp) ? value.match?(node.content_text) : node.content_text.include?(value)
34
+ def match_type(node, value) = value.is_a?(Class) ? node.element.is_a?(value) : node.type == value
35
+ def match_clickable(node, value) = node.clickable? == value
36
+ def match_handler(node, value) = node.handlers.include?(value)
37
+ def match_tooltip(node, value) = text_match(node.tooltip, value)
38
+ def match_visible(node, value) = node.visible? == value
39
+ def match_bg(node, value) = !node.background.nil? && Zaniah::Color.parse(node.background).to_a == Zaniah::Color.parse(value).to_a
40
+ end
41
+
42
+ class Locator
43
+ attr_reader :driver, :criteria, :scope, :index
44
+
45
+ def initialize(driver, criteria, scope: nil, index: nil)
46
+ @driver = driver
47
+ @criteria = criteria
48
+ @scope = scope
49
+ @index = index
50
+ end
51
+
52
+ def find(**conditions, &predicate) = Locator.new(driver, Criteria.new(conditions, predicate), scope: self)
53
+ def nth(index) = Locator.new(driver, criteria, scope: scope, index: index)
54
+ def first = nth(0)
55
+ def last = nth(-1)
56
+
57
+ def candidates(tree = driver.tree)
58
+ pool = scope ? scope.resolve_all(tree).flat_map(&:descendants) : tree.nodes
59
+ pool.select { |node| criteria.match?(node) }
60
+ end
61
+
62
+ def resolve_all(tree = driver.tree)
63
+ found = candidates(tree)
64
+ index.nil? ? found : [found[index]].compact
65
+ end
66
+
67
+ def resolve(tree = driver.tree, strict: driver.strict)
68
+ found = resolve_all(tree)
69
+ raise ElementNotFound.new(self, tree) if found.empty?
70
+ raise AmbiguousMatch.new(self, found) if strict && found.length > 1
71
+
72
+ found.first
73
+ end
74
+
75
+ def count = resolve_all.length
76
+ def exists? = count.positive?
77
+ def visible? = resolve_all.any?(&:visible?)
78
+ def text = resolve.content_text
79
+ def click(**options) = driver.click(self, **options)
80
+ def double_click(**options) = driver.double_click(self, **options)
81
+ def hover = driver.hover(self)
82
+ def right_click = driver.right_click(self)
83
+ def to_s = "#{scope ? "#{scope} >> " : ''}find(#{criteria})#{index.nil? ? '' : "[#{index}]"}"
84
+ end
85
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake"
4
+ require_relative "cli"
5
+
6
+ module Syrma
7
+ class RakeTask
8
+ include Rake::DSL
9
+
10
+ def initialize
11
+ namespace :syrma do
12
+ desc "Check the Syrma and Zaniah environment"
13
+ task(:doctor) { abort unless CLI.run(["doctor"]).zero? }
14
+
15
+ namespace :snapshots do
16
+ desc "Update snapshot goldens"
17
+ task(:update) { abort unless CLI.run(["snapshots", "update"]).zero? }
18
+
19
+ desc "Prune unused snapshot goldens"
20
+ task(:prune) { abort unless CLI.run(["snapshots", "prune"]).zero? }
21
+ end
22
+
23
+ desc "Build an HTML failure report"
24
+ task(:report) { abort unless CLI.run(["report"]).zero? }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syrma
4
+ class Recorder
5
+ def initialize(driver, output)
6
+ @driver = driver
7
+ @output = output
8
+ @started_at = Clock.real_now
9
+ driver.window.testing_recorder = self
10
+ end
11
+
12
+ def record(event)
13
+ target = target_for(event)
14
+ @output.puts(EventCodec.dump(event, t: Clock.real_now - @started_at, target: target))
15
+ @output.flush if @output.respond_to?(:flush)
16
+ end
17
+
18
+ def close
19
+ @driver.window.testing_recorder = nil if @driver.window.testing_recorder.equal?(self)
20
+ end
21
+
22
+ private
23
+
24
+ def target_for(event)
25
+ return unless event.is_a?(Zaniah::Input::MouseDown)
26
+
27
+ node = @driver.at(event.position, event: :mouse_down, button: event.button)
28
+ return unless node
29
+
30
+ {"test_id" => node.test_id, "text" => node.content_text}.compact
31
+ rescue Error
32
+ nil
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "fileutils"
5
+ require "pathname"
6
+
7
+ module Syrma
8
+ module Report
9
+ module_function
10
+
11
+ def generate(root: nil, output: nil)
12
+ root = File.expand_path(root || ENV["SYRMA_ARTIFACTS"] || Syrma.configuration.artifacts_dir)
13
+ output ||= File.join(root, "report.html")
14
+ summaries = Dir[File.join(root, "**", "summary.md")].sort
15
+ FileUtils.mkdir_p(File.dirname(output))
16
+ File.write(output, html(root, summaries), encoding: "UTF-8")
17
+ output
18
+ end
19
+
20
+ def html(root, summaries)
21
+ sections = summaries.map { |path| section(root, path) }.join
22
+ sections = "<p>No failures recorded.</p>" if sections.empty?
23
+ <<~HTML
24
+ <!doctype html>
25
+ <html lang="ja">
26
+ <head>
27
+ <meta charset="utf-8">
28
+ <meta name="viewport" content="width=device-width,initial-scale=1">
29
+ <title>Syrma test report</title>
30
+ <style>body{font:14px system-ui;max-width:1200px;margin:auto;padding:24px;color:#18202a}section{border-top:1px solid #ccd4dd;padding:20px 0}.images{display:flex;gap:12px;overflow:auto}.images figure{margin:0}.images img{max-width:360px;border:1px solid #ccd4dd}pre{white-space:pre-wrap;background:#f4f6f8;padding:12px}</style>
31
+ </head>
32
+ <body><h1>Syrma test report</h1>#{sections}</body>
33
+ </html>
34
+ HTML
35
+ end
36
+
37
+ def section(root, summary_path)
38
+ dir = File.dirname(summary_path)
39
+ title = File.basename(dir)
40
+ summary = ERB::Util.html_escape(File.read(summary_path, encoding: "UTF-8"))
41
+ images = %w[expected.png actual.png diff.png screenshot.png].filter_map do |name|
42
+ path = File.join(dir, name)
43
+ next unless File.exist?(path)
44
+
45
+ relative = Pathname.new(path).relative_path_from(Pathname.new(root)).to_s
46
+ "<figure><figcaption>#{name}</figcaption><img src=\"#{ERB::Util.html_escape(relative)}\" alt=\"#{name}\"></figure>"
47
+ end.join
48
+ details = %w[tree.txt events.log].filter_map do |name|
49
+ path = File.join(dir, name)
50
+ next unless File.exist?(path)
51
+
52
+ "<details><summary>#{name}</summary><pre>#{ERB::Util.html_escape(File.read(path, encoding: 'UTF-8'))}</pre></details>"
53
+ end.join
54
+ "<section><h2>#{ERB::Util.html_escape(title)}</h2><pre>#{summary}</pre><div class=\"images\">#{images}</div>#{details}</section>"
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/expectations"
4
+ require_relative "../syrma"
5
+
6
+ module Syrma
7
+ module RSpec
8
+ module Helpers
9
+ def zaniah_session(**options, &mount)
10
+ (@zaniah_sessions ||= []) << Session.new(**options, &mount)
11
+ @zaniah_sessions.last
12
+ end
13
+
14
+ def ui = @zaniah_sessions&.last || raise(Error, "call zaniah_session first")
15
+ def zaniah_sessions = @zaniah_sessions || []
16
+ end
17
+
18
+ class << self
19
+ def eventually(session, timeout: session.timeout)
20
+ session.wait_for(timeout: timeout) { yield }
21
+ true
22
+ rescue WaitTimeout
23
+ false
24
+ end
25
+
26
+ def snapshot_path(name, extension)
27
+ example = ::RSpec.current_example
28
+ Snapshots::Store.new.path(test_file: example.file_path, test_class: example.example_group.description,
29
+ name: name, ext: extension)
30
+ end
31
+
32
+ def text_snapshot(name, extension, actual)
33
+ store = Snapshots::Store.new
34
+ path = snapshot_path(name, extension)
35
+ if !File.exist?(path) || store.update?
36
+ raise SnapshotMissing, "Snapshot not found: #{path}" if !File.exist?(path) && store.ci? && !store.update?
37
+
38
+ store.write(path, actual)
39
+ return true
40
+ end
41
+ File.read(path, encoding: "UTF-8") == actual
42
+ end
43
+ end
44
+
45
+ ::RSpec::Matchers.define :have_ui_text do |expected|
46
+ match do |session|
47
+ RSpec.eventually(session) { session.texts.any? { |text| expected.is_a?(Regexp) ? expected.match?(text) : text == expected } }
48
+ end
49
+ match_when_negated do |session|
50
+ RSpec.eventually(session) { session.texts.none? { |text| expected.is_a?(Regexp) ? expected.match?(text) : text == expected } }
51
+ end
52
+ failure_message { |session| "Expected text #{expected.inspect} to appear (current: #{session.texts.inspect})" }
53
+ failure_message_when_negated { "Expected text #{expected.inspect} to disappear" }
54
+ end
55
+
56
+ ::RSpec::Matchers.define :have_element do |**criteria|
57
+ match { |session| RSpec.eventually(session) { session.find(**criteria).exists? } }
58
+ match_when_negated { |session| RSpec.eventually(session) { !session.find(**criteria).exists? } }
59
+ failure_message { "Element not found: find(#{Criteria.new(criteria)})" }
60
+ end
61
+
62
+ ::RSpec::Matchers.define :be_visible do
63
+ match { |locator| RSpec.eventually(locator.driver.session) { locator.visible? } }
64
+ failure_message { |locator| "#{locator} is not visible" }
65
+ end
66
+
67
+ ::RSpec::Matchers.define :be_clickable do
68
+ match do |locator|
69
+ RSpec.eventually(locator.driver.session) do
70
+ node = locator.resolve
71
+ locator.driver.__send__(:check_actionable, locator.driver.tree, node, :mouse_down, :left)
72
+ rescue ElementNotFound, NotActionable => error
73
+ @reason = error.message
74
+ false
75
+ end
76
+ end
77
+ failure_message { |locator| "#{locator} is not clickable: #{@reason}" }
78
+ end
79
+
80
+ ::RSpec::Matchers.define :have_bg do |color|
81
+ match { |locator| RSpec.eventually(locator.driver.session) { locator.resolve_all.any? { |node| Criteria.new(bg: color).match?(node) } } }
82
+ failure_message { |locator| "Expected #{locator} background to be #{color.inspect}" }
83
+ end
84
+
85
+ ::RSpec::Matchers.define :have_pixel do |x, y, color, threshold: 2|
86
+ match do |session|
87
+ expected = Zaniah::Color.parse(color).to_a.map { |channel| (channel * 255).round }
88
+ RSpec.eventually(session) do
89
+ session.pixel(x, y).zip(expected).all? { |left, right| (left - right).abs <= threshold }
90
+ end
91
+ end
92
+ failure_message { "Expected pixel (#{x}, #{y}) to be #{color.inspect}" }
93
+ end
94
+
95
+ ::RSpec::Matchers.define :have_tooltip do |text|
96
+ match { |session| RSpec.eventually(session) { session.tooltip == text } }
97
+ failure_message { |session| "Expected tooltip #{text.inspect} (current: #{session.tooltip.inspect})" }
98
+ end
99
+
100
+ ::RSpec::Matchers.define :have_menu_items do |labels|
101
+ match { |session| RSpec.eventually(session) { session.menu.items == labels } }
102
+ failure_message { |session| "Expected menu items #{labels.inspect} (current: #{session.menu.items.inspect})" }
103
+ end
104
+
105
+ ::RSpec::Matchers.define :match_screenshot do |name, **options|
106
+ match do |session|
107
+ store = Snapshots::Store.new
108
+ path = RSpec.snapshot_path(name, "png")
109
+ example = ::RSpec.current_example
110
+ Visual.check_screenshot(session, store: store, path: path,
111
+ artifacts: Diagnostics.dir_for("RSpec", "#{example.full_description}-0"), **options)
112
+ true
113
+ rescue SnapshotMismatch, SnapshotMissing => error
114
+ @reason = error.message
115
+ false
116
+ end
117
+ failure_message { @reason }
118
+ end
119
+
120
+ ::RSpec::Matchers.define :match_tree_snapshot do |name, root: nil, attributes: TreeFormat::DEFAULT|
121
+ match do |session|
122
+ node = root ? (root.is_a?(Locator) ? root.resolve : root) : session.tree.root
123
+ RSpec.text_snapshot(name, "tree.txt", TreeFormat.dump(node, attributes: attributes))
124
+ rescue SnapshotMissing => error
125
+ @reason = error.message
126
+ false
127
+ end
128
+ failure_message { @reason || "Tree snapshot mismatch" }
129
+ end
130
+
131
+ ::RSpec::Matchers.define :match_terminal_snapshot do |name, colors: false|
132
+ match do |session|
133
+ RSpec.text_snapshot(name, "term.txt", session.terminal_lines(colors: colors).join("\n") + "\n")
134
+ rescue SnapshotMissing => error
135
+ @reason = error.message
136
+ false
137
+ end
138
+ failure_message { @reason || "Terminal snapshot mismatch" }
139
+ end
140
+ end
141
+ end
142
+
143
+ ::RSpec.configure do |config|
144
+ config.include Syrma::RSpec::Helpers, type: :zaniah
145
+ config.after(type: :zaniah) do |example|
146
+ zaniah_sessions.each_with_index do |session, index|
147
+ next unless example.exception
148
+
149
+ Syrma::Diagnostics.write(session, Syrma::Diagnostics.dir_for("RSpec", "#{example.full_description}-#{index}"),
150
+ message: example.exception.message)
151
+ end
152
+ zaniah_sessions.each(&:close)
153
+ end
154
+ end