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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +105 -0
- data/exe/syrma +6 -0
- data/lib/syrma/cli.rb +149 -0
- data/lib/syrma/clock.rb +24 -0
- data/lib/syrma/codegen.rb +91 -0
- data/lib/syrma/configuration.rb +17 -0
- data/lib/syrma/diagnostics.rb +64 -0
- data/lib/syrma/errors.rb +33 -0
- data/lib/syrma/event_codec.rb +63 -0
- data/lib/syrma/event_log.rb +22 -0
- data/lib/syrma/instrumentation.rb +80 -0
- data/lib/syrma/internals.rb +26 -0
- data/lib/syrma/minitest.rb +182 -0
- data/lib/syrma/popup_driver.rb +37 -0
- data/lib/syrma/query.rb +85 -0
- data/lib/syrma/rake_task.rb +28 -0
- data/lib/syrma/recorder.rb +35 -0
- data/lib/syrma/report.rb +57 -0
- data/lib/syrma/rspec.rb +154 -0
- data/lib/syrma/script_launcher.rb +62 -0
- data/lib/syrma/session.rb +172 -0
- data/lib/syrma/snapshot.rb +82 -0
- data/lib/syrma/snapshots/store.rb +153 -0
- data/lib/syrma/snapshots/terminal_format.rb +28 -0
- data/lib/syrma/snapshots/tree_format.rb +30 -0
- data/lib/syrma/text_systems.rb +26 -0
- data/lib/syrma/version.rb +5 -0
- data/lib/syrma/visual/comparator.rb +39 -0
- data/lib/syrma/visual/image.rb +31 -0
- data/lib/syrma/window_driver.rb +263 -0
- data/lib/syrma.rb +37 -0
- data/sig/syrma.rbs +266 -0
- metadata +93 -0
|
@@ -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
|
data/lib/syrma/query.rb
ADDED
|
@@ -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
|
data/lib/syrma/report.rb
ADDED
|
@@ -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
|
data/lib/syrma/rspec.rb
ADDED
|
@@ -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
|