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,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syrma
|
|
4
|
+
module ScriptLauncher
|
|
5
|
+
Launch = Struct.new(:fiber, :status, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
module WindowRun
|
|
8
|
+
def run = Fiber.yield(self)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module AppRun
|
|
12
|
+
def run
|
|
13
|
+
return super unless Thread.current[:syrma_script_launch]
|
|
14
|
+
|
|
15
|
+
Fiber.yield(self)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def launch(path, argv: [], backend: :headless, **options)
|
|
22
|
+
install!
|
|
23
|
+
Instrumentation.install!
|
|
24
|
+
opened = []
|
|
25
|
+
launch = Launch.new
|
|
26
|
+
launch.fiber = Fiber.new do
|
|
27
|
+
original_argv = ARGV.dup
|
|
28
|
+
Thread.current[:syrma_script_launch] = true
|
|
29
|
+
ARGV.replace(argv)
|
|
30
|
+
Instrumentation.capture_windows(backend: backend, on_open: lambda { |window|
|
|
31
|
+
opened << window
|
|
32
|
+
window.extend(WindowRun)
|
|
33
|
+
}) { load(File.expand_path(path)) }
|
|
34
|
+
launch.status = 0
|
|
35
|
+
rescue SystemExit => error
|
|
36
|
+
launch.status = error.status
|
|
37
|
+
ensure
|
|
38
|
+
ARGV.replace(original_argv)
|
|
39
|
+
Thread.current[:syrma_script_launch] = nil
|
|
40
|
+
end
|
|
41
|
+
launch.fiber.resume
|
|
42
|
+
raise Error, "Script did not open a window: #{path}" if opened.empty?
|
|
43
|
+
|
|
44
|
+
session = Session.new(**options, backend: backend, attached_windows: opened, owns_windows: true)
|
|
45
|
+
session.launcher = launch
|
|
46
|
+
session
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def finish(launch)
|
|
50
|
+
return unless launch&.fiber&.alive?
|
|
51
|
+
|
|
52
|
+
launch.fiber.resume
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def install!
|
|
56
|
+
return if @installed
|
|
57
|
+
|
|
58
|
+
Zaniah::App.prepend(AppRun)
|
|
59
|
+
@installed = true
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module Syrma
|
|
6
|
+
class Session
|
|
7
|
+
attr_reader :raster, :strict, :timeout, :event_log, :app, :event_frames, :font_paths, :text_mode, :windows, :drivers
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def attach(window, **options) = new(**options, attached_windows: [window], owns_windows: false)
|
|
11
|
+
def launch_script(path, argv: [], **options) = ScriptLauncher.launch(path, argv: argv, **options)
|
|
12
|
+
|
|
13
|
+
def for_app(app, backend: :headless, **options)
|
|
14
|
+
Instrumentation.install!
|
|
15
|
+
windows = Instrumentation.capture_windows(backend: backend) { yield(app) }
|
|
16
|
+
raise Error, "No window was opened inside the block" if windows.empty?
|
|
17
|
+
|
|
18
|
+
new(**options, backend: backend, app: app, attached_windows: windows, owns_windows: true)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(width: 800, height: 600, backend: :headless, text: nil, fonts: nil,
|
|
23
|
+
raster: nil, clock: :virtual, strict: nil, timeout: nil, keymap: nil,
|
|
24
|
+
app: nil, max_frames: 20, event_frames: nil, attached_windows: nil,
|
|
25
|
+
owns_windows: true, **window_options, &mount)
|
|
26
|
+
config = Syrma.configuration
|
|
27
|
+
text ||= config.text
|
|
28
|
+
fonts ||= config.fonts
|
|
29
|
+
strict = config.strict if strict.nil?
|
|
30
|
+
timeout ||= ENV["SYRMA_TIMEOUT"] || config.timeout
|
|
31
|
+
event_frames ||= config.event_frames
|
|
32
|
+
raster ||= ENV["SYRMA_RASTER"] == "eager" ? :eager : :lazy
|
|
33
|
+
validate_options!(backend, event_frames, raster, clock)
|
|
34
|
+
Instrumentation.install!
|
|
35
|
+
|
|
36
|
+
@raster = raster
|
|
37
|
+
@strict = strict
|
|
38
|
+
@app = app
|
|
39
|
+
@max_frames = max_frames
|
|
40
|
+
@clock = clock == :virtual ? Clock.new : (clock == :real ? Zaniah::MONOTONIC_CLOCK : clock)
|
|
41
|
+
@event_frames = event_frames
|
|
42
|
+
@timeout = Float(timeout)
|
|
43
|
+
@event_log = EventLog.new
|
|
44
|
+
@text_mode = text
|
|
45
|
+
@owns_windows = owns_windows
|
|
46
|
+
@backend = backend
|
|
47
|
+
@text = text
|
|
48
|
+
@fonts = fonts
|
|
49
|
+
keymap = VirtualKeymap.new(keymap, @clock) if keymap
|
|
50
|
+
@outputs = {}
|
|
51
|
+
@windows = attached_windows || [open_window(backend, width, height, window_options, keymap)]
|
|
52
|
+
@drivers = @windows.map { |window| prepare(window, backend, text, fonts) }
|
|
53
|
+
@font_paths = text == :deterministic ? [Internals.bundled_font_path, *fonts.map { |font| File.expand_path(font) }] : []
|
|
54
|
+
@current = 0
|
|
55
|
+
mount&.call(window)
|
|
56
|
+
settle
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def window(title: nil, index: nil)
|
|
60
|
+
return @windows[@current] unless title || index
|
|
61
|
+
|
|
62
|
+
selected = index ? @windows[index] : @windows.find { |candidate| candidate.title == title }
|
|
63
|
+
raise Error, "Window not found: #{title ? "title: #{title.inspect}" : "index: #{index}"}" unless selected
|
|
64
|
+
|
|
65
|
+
@current = @windows.index(selected)
|
|
66
|
+
driver
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def driver = @drivers[@current]
|
|
70
|
+
def session = self
|
|
71
|
+
def method_missing(name, ...) = driver.respond_to?(name) ? driver.public_send(name, ...) : super
|
|
72
|
+
def respond_to_missing?(name, include_all = false) = driver.respond_to?(name, include_all) || super
|
|
73
|
+
|
|
74
|
+
def settle
|
|
75
|
+
@max_frames.times do
|
|
76
|
+
@app&.executor&.drain
|
|
77
|
+
sync_app_windows
|
|
78
|
+
busy = false
|
|
79
|
+
@windows.each do |candidate|
|
|
80
|
+
next if candidate.closed? || !candidate.dirty?
|
|
81
|
+
|
|
82
|
+
candidate.tick
|
|
83
|
+
busy = true
|
|
84
|
+
end
|
|
85
|
+
return self unless busy || (@app && !Internals.executor_idle?(@app.executor))
|
|
86
|
+
end
|
|
87
|
+
raise UnstableUI, "UI did not stabilize within #{@max_frames} frames (continuous animation?)"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def advance(seconds)
|
|
91
|
+
raise Error, "advance is unavailable for this clock" unless @clock.respond_to?(:advance)
|
|
92
|
+
|
|
93
|
+
@clock.advance(seconds)
|
|
94
|
+
@windows.reject(&:closed?).each(&:tick)
|
|
95
|
+
settle
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def wait_for(message = "Condition was not met", timeout: @timeout)
|
|
99
|
+
deadline = Clock.real_now + timeout
|
|
100
|
+
loop do
|
|
101
|
+
settle
|
|
102
|
+
result = yield
|
|
103
|
+
return result if result
|
|
104
|
+
|
|
105
|
+
if Clock.real_now >= deadline
|
|
106
|
+
detail = message.respond_to?(:call) ? message.call : message
|
|
107
|
+
raise WaitTimeout, "Timed out after #{timeout} seconds: #{detail}"
|
|
108
|
+
end
|
|
109
|
+
sleep 0.01
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def screenshot_pixels(target = driver)
|
|
114
|
+
settle
|
|
115
|
+
clear = target.window.testing_clear || Zaniah::Platform::Headless::Window::DEFAULT_CLEAR
|
|
116
|
+
target.window.device.render(target.window.scene, clear: clear)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def tui?(target = driver) = @outputs.key?(target.window)
|
|
120
|
+
def output_for(target = driver) = @outputs.fetch(target.window)
|
|
121
|
+
def exit_status = @launcher&.status
|
|
122
|
+
|
|
123
|
+
def launcher=(launcher)
|
|
124
|
+
@launcher = launcher
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def close
|
|
128
|
+
closed = !@owns_windows || @windows.map { |candidate| candidate.closed? || candidate.close }.all?
|
|
129
|
+
ScriptLauncher.finish(@launcher) if @launcher
|
|
130
|
+
closed
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def validate_options!(backend, event_frames, raster, clock)
|
|
136
|
+
raise ArgumentError, "backend must be :headless or :tui" unless %i[headless tui].include?(backend)
|
|
137
|
+
raise ArgumentError, "event_frames must be :each or :gesture" unless %i[each gesture].include?(event_frames)
|
|
138
|
+
raise ArgumentError, "raster must be :lazy or :eager" unless %i[lazy eager].include?(raster)
|
|
139
|
+
raise ArgumentError, "clock must be :virtual, :real, or callable" unless %i[virtual real].include?(clock) || clock.respond_to?(:call)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def open_window(backend, width, height, options, keymap)
|
|
143
|
+
options = options.merge(clock: @clock)
|
|
144
|
+
options[:keymap] = keymap if keymap
|
|
145
|
+
if backend == :tui
|
|
146
|
+
output = options[:output] || StringIO.new
|
|
147
|
+
window = Zaniah::Platform.open_window(backend: backend, width: width, height: height,
|
|
148
|
+
input: options[:input] || StringIO.new, output: output,
|
|
149
|
+
**options.except(:input, :output))
|
|
150
|
+
@outputs[window] = output
|
|
151
|
+
window
|
|
152
|
+
else
|
|
153
|
+
Zaniah::Platform.open_window(backend: backend, width: width, height: height, **options)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def prepare(candidate, backend, text, fonts)
|
|
158
|
+
actual_backend = candidate.is_a?(Zaniah::Platform::TUI::Window) ? :tui : backend
|
|
159
|
+
candidate.text_system = TextSystems.build(text, fonts: fonts) unless actual_backend == :tui
|
|
160
|
+
WindowDriver.new(candidate, self)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def sync_app_windows
|
|
164
|
+
return unless @app
|
|
165
|
+
|
|
166
|
+
(@app.windows - @windows).each do |candidate|
|
|
167
|
+
@windows << candidate
|
|
168
|
+
@drivers << prepare(candidate, @backend, @text, @fonts)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syrma
|
|
4
|
+
Node = Data.define(:path, :element, :type, :test_id, :key, :text, :color, :font_size,
|
|
5
|
+
:bounds, :visible_bounds, :background, :border_color, :radius,
|
|
6
|
+
:handlers, :tooltip, :children) do
|
|
7
|
+
def visible? = visible_bounds.width.positive? && visible_bounds.height.positive?
|
|
8
|
+
def clickable? = handlers.include?(:click) || handlers.include?(:mouse_down)
|
|
9
|
+
def center = Zaniah::Point.new(visible_bounds.x + visible_bounds.width / 2.0,
|
|
10
|
+
visible_bounds.y + visible_bounds.height / 2.0)
|
|
11
|
+
def descendants = children.flat_map { |child| [child, *child.descendants] }
|
|
12
|
+
def content_text = [text, *descendants.map(&:text)].compact.join(" ")
|
|
13
|
+
def ancestor_of?(other) = other.path.length > path.length && other.path.first(path.length) == path
|
|
14
|
+
def inspect = "#<#{type}#{test_id && " @#{test_id}"}#{text && " #{text.inspect}"} #{path.join('.')}>"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
Tree = Data.define(:frame, :size, :root, :nodes, :hits, :text_runs, :menu, :menu_index, :tooltip) do
|
|
18
|
+
def hit_at(point, event: :mouse_down, button: :left)
|
|
19
|
+
hits.reverse_each do |bounds, node|
|
|
20
|
+
next unless bounds.contains?(point)
|
|
21
|
+
|
|
22
|
+
return node if node.nil? || Tree.handles?(node, event, button)
|
|
23
|
+
end
|
|
24
|
+
nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.handles?(node, event, button)
|
|
28
|
+
case event
|
|
29
|
+
when :mouse_down
|
|
30
|
+
(button == :right && Internals.context_menu(node.element)) ||
|
|
31
|
+
(node.handlers & %i[mouse_down click drag]).any?
|
|
32
|
+
when :mouse_move then node.handlers.include?(:hover) || node.tooltip
|
|
33
|
+
when :mouse_up then node.handlers.include?(:mouse_up)
|
|
34
|
+
when :scroll then node.handlers.include?(:scroll_wheel)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class SnapshotBuilder
|
|
40
|
+
def build(window)
|
|
41
|
+
by_element = {}.compare_by_identity
|
|
42
|
+
nodes = []
|
|
43
|
+
viewport = Zaniah::Bounds.new(0, 0, window.content_size.width, window.content_size.height)
|
|
44
|
+
root = window.testing_root && visit(window.testing_root, viewport, [0], nodes, by_element)
|
|
45
|
+
hits = window.dispatcher.hits.map { |hit| [hit.bounds, hit.owner && by_element[hit.owner]] }
|
|
46
|
+
popup = window.popup
|
|
47
|
+
Tree.new(frame: window.testing_frame, size: window.content_size, root: root,
|
|
48
|
+
nodes: nodes.freeze, hits: hits.freeze, text_runs: window.text_runs.map(&:dup).freeze,
|
|
49
|
+
menu: popup&.labels, menu_index: popup&.selected_index,
|
|
50
|
+
tooltip: Internals.shown_tooltip(window))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def visit(element, clip, path, nodes, by_element)
|
|
56
|
+
bounds = element.layout_node&.bounds or return nil
|
|
57
|
+
style = Internals.style(element)
|
|
58
|
+
return nil if style[:display] == :none
|
|
59
|
+
|
|
60
|
+
visible = bounds.intersect(clip)
|
|
61
|
+
child_clip = style[:overflow] == :visible ? clip : visible
|
|
62
|
+
index = nodes.length
|
|
63
|
+
nodes << nil
|
|
64
|
+
children = element.children.each_with_index.filter_map do |child, child_index|
|
|
65
|
+
visit(child, child_clip, path + [child_index], nodes, by_element)
|
|
66
|
+
end
|
|
67
|
+
node = Node.new(
|
|
68
|
+
path: path.freeze, element: element, type: element.class.name.split("::").last.downcase.to_sym,
|
|
69
|
+
test_id: element.test_id, key: Internals.key(element),
|
|
70
|
+
text: text_attr(element, :text), color: text_attr(element, :text_color),
|
|
71
|
+
font_size: text_attr(element, :font_size), bounds: bounds, visible_bounds: visible,
|
|
72
|
+
background: Internals.background(element), border_color: Internals.border_color(element),
|
|
73
|
+
radius: Internals.radius(element), handlers: element.handlers,
|
|
74
|
+
tooltip: Internals.tooltip(element), children: children.freeze
|
|
75
|
+
)
|
|
76
|
+
nodes[index] = node
|
|
77
|
+
by_element[element] = node
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def text_attr(element, name) = element.is_a?(Zaniah::Text) ? element.public_send(name) : nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Syrma
|
|
8
|
+
module Snapshots
|
|
9
|
+
class Store
|
|
10
|
+
NAME = /\A[A-Za-z0-9_-]+\z/
|
|
11
|
+
|
|
12
|
+
attr_reader :root
|
|
13
|
+
|
|
14
|
+
def initialize(root: nil, env: ENV)
|
|
15
|
+
@root = File.expand_path(root || env["SYRMA_SNAPSHOT_DIR"] || Syrma.configuration.snapshot_dir)
|
|
16
|
+
@env = env
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def update? = @env["SYRMA_UPDATE_SNAPSHOTS"] == "1"
|
|
20
|
+
def ci? = !@env["CI"].to_s.empty?
|
|
21
|
+
|
|
22
|
+
def path(test_file:, test_class:, name:, ext:)
|
|
23
|
+
unless name.to_s.match?(NAME)
|
|
24
|
+
raise ArgumentError, "Snapshot names may contain only letters, digits, hyphens, and underscores: #{name.inspect}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
dir = File.join(root, File.basename(test_file.to_s, ".rb"), test_class.to_s.gsub(/[^A-Za-z0-9_-]/, "_"))
|
|
28
|
+
File.join(dir, "#{name}.#{ext}").tap do |full|
|
|
29
|
+
raise ArgumentError, "Snapshot path is outside the configured directory" unless full.start_with?(root + File::SEPARATOR)
|
|
30
|
+
|
|
31
|
+
record(full)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def write(path, bytes)
|
|
36
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
37
|
+
File.binwrite(path, bytes)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def usage_path
|
|
41
|
+
File.join(ENV["SYRMA_ARTIFACTS"] || Syrma.configuration.artifacts_dir, "used-snapshots.txt")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reset_usage!(full: false)
|
|
45
|
+
FileUtils.mkdir_p(File.dirname(usage_path))
|
|
46
|
+
File.write(usage_path, full ? "# full\n" : "# partial\n", encoding: "UTF-8")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def unused
|
|
50
|
+
return [] unless File.exist?(usage_path)
|
|
51
|
+
|
|
52
|
+
lines = File.readlines(usage_path, chomp: true, encoding: "UTF-8")
|
|
53
|
+
raise Error, "Cannot prune snapshots after a partial test run" unless lines.first == "# full"
|
|
54
|
+
|
|
55
|
+
used = lines.drop(1).to_h { |path| [File.expand_path(path), true] }
|
|
56
|
+
Dir[File.join(root, "**", "*")].select { |path| File.file?(path) && !used[File.expand_path(path)] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def record(path)
|
|
62
|
+
FileUtils.mkdir_p(File.dirname(usage_path))
|
|
63
|
+
paths = [path]
|
|
64
|
+
paths << path.sub(/\.png\z/, ".meta.json") if path.end_with?(".png")
|
|
65
|
+
File.open(usage_path, "a", encoding: "UTF-8") { |file| paths.each { |used| file.puts(used) } }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
module Visual
|
|
71
|
+
module_function
|
|
72
|
+
|
|
73
|
+
def meta(session)
|
|
74
|
+
window = session.window
|
|
75
|
+
text = window.text_system
|
|
76
|
+
{
|
|
77
|
+
zaniah: Zaniah::VERSION,
|
|
78
|
+
size: [window.content_size.width, window.content_size.height],
|
|
79
|
+
scale_factor: window.scale_factor,
|
|
80
|
+
text: text&.class&.name,
|
|
81
|
+
fonts: session.font_paths.map { |path| Digest::SHA256.file(path).hexdigest[0, 16] }
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def check_screenshot(session, store:, path:, threshold: 2, max_diff_pixels: 0,
|
|
86
|
+
max_diff_ratio: nil, region: nil, mask: [], artifacts: nil)
|
|
87
|
+
validate_limits!(max_diff_pixels, max_diff_ratio)
|
|
88
|
+
width = session.window.content_size.width.to_i
|
|
89
|
+
height = session.window.content_size.height.to_i
|
|
90
|
+
actual = session.screenshot_pixels.dup
|
|
91
|
+
Array(mask).each { |target| Image.mask!(actual, width, height, bounds_for(target)) }
|
|
92
|
+
width, height, actual = Image.crop(actual, width, height, bounds_for(region)) if region
|
|
93
|
+
png = Zaniah::PNG.encode(width, height, actual)
|
|
94
|
+
meta_path = path.sub(/\.png\z/, ".meta.json")
|
|
95
|
+
|
|
96
|
+
unless File.exist?(path)
|
|
97
|
+
if store.ci? && !store.update?
|
|
98
|
+
raise SnapshotMissing, "Snapshot not found: #{path} (set SYRMA_UPDATE_SNAPSHOTS=1 to create it)"
|
|
99
|
+
end
|
|
100
|
+
write_golden(store, path, meta_path, png, session)
|
|
101
|
+
return :created
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
expected_width, expected_height, expected = Zaniah::PNG.decode(File.binread(path))
|
|
105
|
+
result = if [expected_width, expected_height] == [width, height]
|
|
106
|
+
Comparator.new(threshold: threshold).compare(width, height, expected, actual)
|
|
107
|
+
end
|
|
108
|
+
valid = result && result.diff_pixels <= max_diff_pixels &&
|
|
109
|
+
(max_diff_ratio.nil? || result.ratio <= max_diff_ratio)
|
|
110
|
+
return :matched if valid
|
|
111
|
+
|
|
112
|
+
if store.update?
|
|
113
|
+
write_golden(store, path, meta_path, png, session)
|
|
114
|
+
return :updated
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
write_diff_artifacts(artifacts, path, png, width, height, result) if artifacts
|
|
118
|
+
old_meta = File.exist?(meta_path) ? JSON.parse(File.read(meta_path, encoding: "UTF-8")) : {}
|
|
119
|
+
font_hint = old_meta["fonts"] && old_meta["fonts"] != meta(session)[:fonts] ? "\n Font configuration differs from the snapshot" : ""
|
|
120
|
+
detail = if result
|
|
121
|
+
"#{result.diff_pixels} differing pixels (#{(result.ratio * 100).round(3)}%)"
|
|
122
|
+
else
|
|
123
|
+
"size mismatch: expected #{expected_width}x#{expected_height}, got #{width}x#{height}"
|
|
124
|
+
end
|
|
125
|
+
raise SnapshotMismatch, "Image snapshot mismatch: #{File.basename(path)} #{detail}#{font_hint}" +
|
|
126
|
+
(artifacts ? "\n Artifacts: #{artifacts}" : "")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def bounds_for(target)
|
|
130
|
+
node = target.is_a?(Locator) ? target.resolve : target
|
|
131
|
+
node.respond_to?(:bounds) ? node.bounds : node
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def validate_limits!(pixels, ratio)
|
|
135
|
+
raise ArgumentError, "max_diff_pixels must be a non-negative integer" unless pixels.is_a?(Integer) && pixels >= 0
|
|
136
|
+
return if ratio.nil? || (ratio.is_a?(Numeric) && ratio.between?(0, 1))
|
|
137
|
+
|
|
138
|
+
raise ArgumentError, "max_diff_ratio must be between 0.0 and 1.0"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def write_golden(store, path, meta_path, png, session)
|
|
142
|
+
store.write(path, png)
|
|
143
|
+
store.write(meta_path, JSON.pretty_generate(meta(session)))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def write_diff_artifacts(dir, expected_path, actual_png, width, height, result)
|
|
147
|
+
FileUtils.mkdir_p(dir)
|
|
148
|
+
File.binwrite(File.join(dir, "expected.png"), File.binread(expected_path))
|
|
149
|
+
File.binwrite(File.join(dir, "actual.png"), actual_png)
|
|
150
|
+
Zaniah::PNG.write(File.join(dir, "diff.png"), width, height, result.diff_image) if result
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syrma
|
|
4
|
+
module TerminalFormat
|
|
5
|
+
ROW = 20
|
|
6
|
+
COL = 8
|
|
7
|
+
ANSI = /\e\[[0-9;?]*[A-Za-z]/
|
|
8
|
+
RGB = /\e\[38;2;(\d+);(\d+);(\d+)m/
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def lines(output, colors: false)
|
|
13
|
+
frame = output.split("\e[H").last.to_s
|
|
14
|
+
frame = frame.gsub(RGB) { format("{#%02x%02x%02x}", $1.to_i, $2.to_i, $3.to_i) } if colors
|
|
15
|
+
frame.gsub(ANSI, "").split("\r\n").map(&:rstrip)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def overlaps(text_runs)
|
|
19
|
+
cells = text_runs.map do |x, y, text, _color|
|
|
20
|
+
column = (x / COL).floor
|
|
21
|
+
[(y / ROW).floor, column, column + Zaniah::Unicode.width(text.to_s), text]
|
|
22
|
+
end
|
|
23
|
+
cells.combination(2).filter_map do |first, second|
|
|
24
|
+
[first[3], second[3], first[0]] if first[0] == second[0] && first[1] < second[2] && second[1] < first[2]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syrma
|
|
4
|
+
module TreeFormat
|
|
5
|
+
DEFAULT = %i[test_id text bounds bg handlers tooltip].freeze
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def dump(node, attributes: DEFAULT, depth: 0, out: +"")
|
|
10
|
+
parts = [node.type.to_s]
|
|
11
|
+
parts << "@#{node.test_id}" if attributes.include?(:test_id) && node.test_id
|
|
12
|
+
parts << node.text.inspect if attributes.include?(:text) && node.text
|
|
13
|
+
if attributes.include?(:bounds)
|
|
14
|
+
bounds = node.bounds
|
|
15
|
+
parts << "[#{num(bounds.x)},#{num(bounds.y)} #{num(bounds.width)}x#{num(bounds.height)}]"
|
|
16
|
+
end
|
|
17
|
+
parts << "bg=#{node.background}" if attributes.include?(:bg) && node.background && node.background != "#0000"
|
|
18
|
+
parts << "on=#{node.handlers.join(',')}" if attributes.include?(:handlers) && node.handlers.any?
|
|
19
|
+
parts << "tooltip=#{node.tooltip.inspect}" if attributes.include?(:tooltip) && node.tooltip
|
|
20
|
+
out << (" " * depth) << parts.join(" ") << "\n"
|
|
21
|
+
node.children.each { |child| dump(child, attributes: attributes, depth: depth + 1, out: out) }
|
|
22
|
+
out
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def num(value)
|
|
26
|
+
rounded = value.to_f.round(2)
|
|
27
|
+
rounded == rounded.to_i ? rounded.to_i.to_s : rounded.to_s
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syrma
|
|
4
|
+
module TextSystems
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def build(mode, fonts: [])
|
|
8
|
+
case mode
|
|
9
|
+
when :none, nil then nil
|
|
10
|
+
when :native then Zaniah::TextSystem::Renderer.new
|
|
11
|
+
when :deterministic
|
|
12
|
+
paths = [Internals.bundled_font_path, *fonts.map { |font| File.expand_path(font) }]
|
|
13
|
+
missing = paths.reject { |path| File.file?(path) }
|
|
14
|
+
raise ArgumentError, "Fonts not found: #{missing.join(', ')}" unless missing.empty?
|
|
15
|
+
|
|
16
|
+
db = Zaniah::TextSystem::FontDB.new(paths: paths)
|
|
17
|
+
primary = fonts.empty? ? paths.first : paths[1]
|
|
18
|
+
Zaniah::TextSystem::Renderer.new(font: db.open(primary), font_db: db)
|
|
19
|
+
else
|
|
20
|
+
raise ArgumentError, "Unsupported text mode: #{mode.inspect}" unless mode.respond_to?(:layout_line)
|
|
21
|
+
|
|
22
|
+
mode
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syrma
|
|
4
|
+
class Comparator
|
|
5
|
+
Result = Data.define(:width, :height, :diff_pixels, :diff_image) do
|
|
6
|
+
def ratio = diff_pixels.fdiv(width * height)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def initialize(threshold: 0)
|
|
10
|
+
raise ArgumentError, "threshold must be between 0 and 255" unless threshold.is_a?(Numeric) && threshold.between?(0, 255)
|
|
11
|
+
|
|
12
|
+
@threshold = threshold
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def compare(width, height, expected, actual)
|
|
16
|
+
expected_size = width * height * 4
|
|
17
|
+
unless expected.bytesize == actual.bytesize && expected.bytesize == expected_size
|
|
18
|
+
raise ArgumentError, "Image dimensions do not match"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
diff = String.new(capacity: expected_size, encoding: Encoding::BINARY)
|
|
22
|
+
count = 0
|
|
23
|
+
expected_bytes = expected.unpack("C*")
|
|
24
|
+
actual_bytes = actual.unpack("C*")
|
|
25
|
+
(width * height).times do |index|
|
|
26
|
+
offset = index * 4
|
|
27
|
+
different = 4.times.any? { |channel| (expected_bytes[offset + channel] - actual_bytes[offset + channel]).abs > @threshold }
|
|
28
|
+
if different
|
|
29
|
+
count += 1
|
|
30
|
+
diff << [255, 0, 0, 255].pack("C4")
|
|
31
|
+
else
|
|
32
|
+
gray = ((expected_bytes[offset] * 30 + expected_bytes[offset + 1] * 59 + expected_bytes[offset + 2] * 11) / 300 + 170).clamp(0, 255)
|
|
33
|
+
diff << [gray, gray, gray, 255].pack("C4")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
Result.new(width: width, height: height, diff_pixels: count, diff_image: diff)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syrma
|
|
4
|
+
module Visual
|
|
5
|
+
module Image
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def crop(rgba, width, height, bounds)
|
|
9
|
+
x = bounds.x.floor.clamp(0, width)
|
|
10
|
+
y = bounds.y.floor.clamp(0, height)
|
|
11
|
+
cropped_width = bounds.right.ceil.clamp(0, width) - x
|
|
12
|
+
cropped_height = bounds.bottom.ceil.clamp(0, height) - y
|
|
13
|
+
out = String.new(capacity: cropped_width * cropped_height * 4, encoding: Encoding::BINARY)
|
|
14
|
+
cropped_height.times do |row|
|
|
15
|
+
out << rgba.byteslice(((y + row) * width + x) * 4, cropped_width * 4)
|
|
16
|
+
end
|
|
17
|
+
[cropped_width, cropped_height, out]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def mask!(rgba, width, height, bounds, color: [255, 0, 255, 255])
|
|
21
|
+
pixel = color.pack("C4")
|
|
22
|
+
(bounds.y.floor.clamp(0, height)...bounds.bottom.ceil.clamp(0, height)).each do |row|
|
|
23
|
+
(bounds.x.floor.clamp(0, width)...bounds.right.ceil.clamp(0, width)).each do |column|
|
|
24
|
+
rgba[(row * width + column) * 4, 4] = pixel
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
rgba
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|