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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e9ef547e116ba3d928630699b7ae95646b0604108499429bc978bf31faa502bf
|
|
4
|
+
data.tar.gz: 01de9448ef8c33a18af96280ba15a993cf08be62292408de956aa8be78572a8c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 15af45c54231f18c6c10b7db2354a644bc206c584c515a169672ad562db43753f5753ececea2c829b04d418b83181c6689c0571be115dea91109425b74bc0342
|
|
7
|
+
data.tar.gz: 15c60ea282171fb9e17427769fd81b5510218590afb0126d51349bf16a73c5b5a58b9657fe0a27e3d1140a3fcfc3e70270c013c02445dbedc71ce0cbb93b2640
|
data/CHANGELOG.md
ADDED
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Yudai Takada
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Syrma
|
|
2
|
+
|
|
3
|
+
Syrma drives [Zaniah](https://github.com/noxdea/zaniah) GUI and TUI applications from Ruby tests. It locates rendered elements, sends input through Zaniah's real event path, waits for redraws, and compares text, trees, terminal output, pixels, and screenshots.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add Syrma to the test group in your `Gemfile`:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
group :test do
|
|
11
|
+
gem "minitest", "~> 5.0"
|
|
12
|
+
gem "syrma"
|
|
13
|
+
end
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Then run `bundle install` and `bundle exec syrma doctor`.
|
|
17
|
+
|
|
18
|
+
## Minimal test
|
|
19
|
+
|
|
20
|
+
Keep window creation separate from the code that mounts your UI:
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
module Counter
|
|
24
|
+
def self.mount(window)
|
|
25
|
+
count = 0
|
|
26
|
+
window.draw do
|
|
27
|
+
Zaniah::Div.new
|
|
28
|
+
.child(Zaniah::Text.new("Count: #{count}"))
|
|
29
|
+
.child(Zaniah::Div.new.test_id("increment").on_click { count += 1 })
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
require "syrma/minitest"
|
|
37
|
+
|
|
38
|
+
class CounterTest < Minitest::Test
|
|
39
|
+
include Syrma::Minitest
|
|
40
|
+
|
|
41
|
+
def setup
|
|
42
|
+
zaniah_session(width: 320, height: 200) { |window| Counter.mount(window) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def test_increment
|
|
46
|
+
ui.find(test_id: "increment").click
|
|
47
|
+
assert_ui_text "Count: 1"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`test_id` is provided by Zaniah 0.2 and is safe to use in application code.
|
|
53
|
+
|
|
54
|
+
## API at a glance
|
|
55
|
+
|
|
56
|
+
| Task | API |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| Locate | `find`, `all`, `test_id`, `text`, `button`, nested `find`, `nth`, `first`, `last` |
|
|
59
|
+
| Pointer | `click`, `double_click`, `right_click`, `hover`, `drag`, `scroll` |
|
|
60
|
+
| Keyboard/text | `press`, `type`, `paste`, `compose`, `commit` |
|
|
61
|
+
| Window/input | `resize`, `close`, `drop_files`, `feed_terminal` |
|
|
62
|
+
| Synchronize | `settle`, `wait_for`, `advance` |
|
|
63
|
+
| Inspect | `tree`, `texts`, `at`, `pixel`, `screenshot`, `terminal_lines`, `menu`, `tooltip` |
|
|
64
|
+
| Assert | text, element, visibility, clickability, background, pixel, tooltip, menu, tree/terminal/image snapshots |
|
|
65
|
+
|
|
66
|
+
Locators are lazy: every operation resolves them against the latest rendered frame. Actions wait for visibility and an unobscured matching event handler, then send events through `Window#input`.
|
|
67
|
+
|
|
68
|
+
## Snapshots and diagnostics
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
assert_tree_snapshot "sidebar"
|
|
72
|
+
assert_screenshot "saved", region: ui.test_id("panel"), mask: [ui.test_id("clock")]
|
|
73
|
+
assert_terminal_snapshot "main"
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
New goldens are created locally and rejected on CI. Set `SYRMA_UPDATE_SNAPSHOTS=1` to update them. A failed UI test writes its screenshot, element tree, hit regions, text runs, recent events, and summary below `tmp/syrma`.
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
bundle exec syrma snapshots update
|
|
80
|
+
bundle exec syrma snapshots prune --dry-run
|
|
81
|
+
bundle exec syrma report
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Determinism and speed
|
|
85
|
+
|
|
86
|
+
The default text renderer uses only Zaniah's bundled Abel font plus files passed in `fonts:`. Add a repository-owned font for non-Latin screenshot tests. `text: :none` is faster but does not draw glyphs.
|
|
87
|
+
|
|
88
|
+
On Ruby 4.0 arm64 macOS, the included 101-element benchmark measured `event_frames: :gesture` at 3.1 ms per click/check with `text: :none` and 7.9 ms with deterministic text; Syrma's tree build plus locator resolution was about 0.28 ms. Run `bundle exec ruby -Ilib bench/click_bench.rb gesture` on the target CI host for relevant numbers.
|
|
89
|
+
|
|
90
|
+
See [the guide](docs/guide.md) and [recipes](docs/recipes.md) for the full workflow.
|
|
91
|
+
|
|
92
|
+
## Development
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
bundle exec rake
|
|
96
|
+
bundle exec ruby -Ilib:test script/test_gesture.rb
|
|
97
|
+
bundle exec rbs -I sig validate
|
|
98
|
+
gem build --strict syrma.gemspec
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Syrma supports Ruby 3.1+ and Zaniah `~> 0.2.0`. Virtual time is scoped to each session.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
Syrma is available under the [MIT License](LICENSE.txt).
|
data/exe/syrma
ADDED
data/lib/syrma/cli.rb
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "optparse"
|
|
5
|
+
require_relative "../syrma"
|
|
6
|
+
|
|
7
|
+
module Syrma
|
|
8
|
+
class CLI
|
|
9
|
+
def self.run(argv = ARGV, out: $stdout, err: $stderr)
|
|
10
|
+
new(out: out, err: err).run(argv.dup)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(out:, err:)
|
|
14
|
+
@out = out
|
|
15
|
+
@err = err
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run(arguments)
|
|
19
|
+
command = arguments.shift
|
|
20
|
+
case command
|
|
21
|
+
when "doctor" then doctor
|
|
22
|
+
when "snapshots" then snapshots(arguments)
|
|
23
|
+
when "report" then report(arguments)
|
|
24
|
+
when "record" then record(arguments)
|
|
25
|
+
when "codegen" then codegen(arguments)
|
|
26
|
+
when nil, "help", "--help", "-h" then help
|
|
27
|
+
else
|
|
28
|
+
@err.puts "Unknown command: #{command}"
|
|
29
|
+
help(@err)
|
|
30
|
+
1
|
|
31
|
+
end
|
|
32
|
+
rescue OptionParser::ParseError, ArgumentError, Error => error
|
|
33
|
+
@err.puts error.message
|
|
34
|
+
1
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def doctor
|
|
40
|
+
checks = {
|
|
41
|
+
"Ruby" => "#{RUBY_VERSION} (#{RUBY_PLATFORM})",
|
|
42
|
+
"zaniah" => Zaniah::VERSION,
|
|
43
|
+
"Bundled font" => File.file?(Internals.bundled_font_path) ? Internals.bundled_font_path : nil,
|
|
44
|
+
"YJIT" => defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? ? "enabled" : "disabled",
|
|
45
|
+
"External encoding" => Encoding.default_external.name
|
|
46
|
+
}
|
|
47
|
+
checks.each { |name, value| @out.puts format("%-14s %s", name, value || "NG") }
|
|
48
|
+
checks.values.all? ? 0 : 1
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def snapshots(arguments)
|
|
52
|
+
subcommand = arguments.shift
|
|
53
|
+
case subcommand
|
|
54
|
+
when "update" then update_snapshots(arguments)
|
|
55
|
+
when "prune" then prune_snapshots(arguments)
|
|
56
|
+
else raise ArgumentError, "Usage: syrma snapshots update [test files] | prune [--dry-run]"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def update_snapshots(files)
|
|
61
|
+
Snapshots::Store.new.reset_usage!(full: files.empty?)
|
|
62
|
+
env = {"SYRMA_UPDATE_SNAPSHOTS" => "1"}
|
|
63
|
+
command = ["bundle", "exec", "rake", "test"]
|
|
64
|
+
command << "TEST=#{files.join(' ')}" unless files.empty?
|
|
65
|
+
system(env, *command) ? 0 : 1
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def prune_snapshots(arguments)
|
|
69
|
+
dry_run = arguments.delete("--dry-run")
|
|
70
|
+
raise ArgumentError, "Unknown arguments: #{arguments.join(' ')}" unless arguments.empty?
|
|
71
|
+
|
|
72
|
+
files = Snapshots::Store.new.unused
|
|
73
|
+
files.each { |path| @out.puts(dry_run ? "would remove #{path}" : "removed #{path}") }
|
|
74
|
+
files.each { |path| FileUtils.rm_f(path) } unless dry_run
|
|
75
|
+
0
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def report(arguments)
|
|
79
|
+
open_report = arguments.delete("--open")
|
|
80
|
+
raise ArgumentError, "Unknown arguments: #{arguments.join(' ')}" unless arguments.empty?
|
|
81
|
+
|
|
82
|
+
path = Report.generate
|
|
83
|
+
@out.puts path
|
|
84
|
+
open_path(path) if open_report
|
|
85
|
+
0
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def record(arguments)
|
|
89
|
+
output = "recording.jsonl"
|
|
90
|
+
parser = OptionParser.new { |options| options.on("--out PATH") { |path| output = path } }
|
|
91
|
+
parser.parse!(arguments)
|
|
92
|
+
script = arguments.shift or raise ArgumentError, "Usage: syrma record SCRIPT [--out PATH]"
|
|
93
|
+
raise ArgumentError, "Unknown arguments: #{arguments.join(' ')}" unless arguments.empty?
|
|
94
|
+
|
|
95
|
+
sessions = []
|
|
96
|
+
recorders = []
|
|
97
|
+
File.open(output, "w", encoding: "UTF-8") do |file|
|
|
98
|
+
Instrumentation.install!
|
|
99
|
+
Instrumentation.capture_windows(backend: nil, on_open: lambda { |window|
|
|
100
|
+
session = Session.attach(window, text: :native, raster: :eager)
|
|
101
|
+
sessions << session
|
|
102
|
+
recorders << Recorder.new(session.driver, file)
|
|
103
|
+
}) { load(File.expand_path(script)) }
|
|
104
|
+
end
|
|
105
|
+
@out.puts output
|
|
106
|
+
0
|
|
107
|
+
ensure
|
|
108
|
+
recorders&.each(&:close)
|
|
109
|
+
sessions&.each(&:close)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def codegen(arguments)
|
|
113
|
+
framework = :minitest
|
|
114
|
+
parser = OptionParser.new { |options| options.on("--framework NAME") { |name| framework = name.to_sym } }
|
|
115
|
+
parser.parse!(arguments)
|
|
116
|
+
raise ArgumentError, "framework must be minitest or rspec" unless %i[minitest rspec].include?(framework)
|
|
117
|
+
|
|
118
|
+
path = arguments.shift or raise ArgumentError, "Usage: syrma codegen RECORDING [--framework minitest|rspec]"
|
|
119
|
+
raise ArgumentError, "Unknown arguments: #{arguments.join(' ')}" unless arguments.empty?
|
|
120
|
+
|
|
121
|
+
@out.write(Codegen.generate(File.readlines(path, encoding: "UTF-8"), framework: framework))
|
|
122
|
+
0
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def open_path(path)
|
|
126
|
+
command = if RUBY_PLATFORM.include?("darwin")
|
|
127
|
+
["open", path]
|
|
128
|
+
elsif RUBY_PLATFORM.match?(/mswin|mingw/)
|
|
129
|
+
["cmd", "/c", "start", "", path]
|
|
130
|
+
else
|
|
131
|
+
["xdg-open", path]
|
|
132
|
+
end
|
|
133
|
+
system(*command)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def help(io = @out)
|
|
137
|
+
io.puts <<~HELP
|
|
138
|
+
Usage: syrma COMMAND
|
|
139
|
+
doctor
|
|
140
|
+
snapshots update [test files]
|
|
141
|
+
snapshots prune [--dry-run]
|
|
142
|
+
report [--open]
|
|
143
|
+
record SCRIPT [--out recording.jsonl]
|
|
144
|
+
codegen RECORDING [--framework minitest|rspec]
|
|
145
|
+
HELP
|
|
146
|
+
0
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
data/lib/syrma/clock.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syrma
|
|
4
|
+
class Clock
|
|
5
|
+
def self.real_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
6
|
+
|
|
7
|
+
def initialize = @now = self.class.real_now
|
|
8
|
+
def call = @now
|
|
9
|
+
|
|
10
|
+
def advance(seconds)
|
|
11
|
+
raise ArgumentError, "time cannot be negative" unless seconds.is_a?(Numeric) && seconds >= 0
|
|
12
|
+
|
|
13
|
+
@now += seconds.to_f
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class VirtualKeymap
|
|
18
|
+
def initialize(keymap, clock) = (@keymap, @clock = keymap, clock)
|
|
19
|
+
|
|
20
|
+
def dispatch(key, context: {})
|
|
21
|
+
@keymap.dispatch(key, context: context, now: @clock.call)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syrma
|
|
4
|
+
module Codegen
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def generate(lines, framework: :minitest)
|
|
8
|
+
records = lines.filter_map { |line| EventCodec.parse(line) unless line.strip.empty? }
|
|
9
|
+
body = operations(records).map { |line| " #{line}" }.join("\n")
|
|
10
|
+
framework == :rspec ? rspec(body) : minitest(body)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def operations(records)
|
|
14
|
+
output = []
|
|
15
|
+
text = +""
|
|
16
|
+
flush = -> { output << "ui.type(#{text.dump})" unless text.empty?; text.clear }
|
|
17
|
+
records.each_with_index do |record, index|
|
|
18
|
+
if record["type"] == "TextInput"
|
|
19
|
+
text << record.fetch("fields").fetch("text")
|
|
20
|
+
next
|
|
21
|
+
end
|
|
22
|
+
next if record["type"] == "KeyUp" || typing_key?(records, index)
|
|
23
|
+
|
|
24
|
+
flush.call
|
|
25
|
+
operation = operation(record)
|
|
26
|
+
output << operation if operation
|
|
27
|
+
end
|
|
28
|
+
flush.call
|
|
29
|
+
output << "# TODO: add assertions"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def operation(record)
|
|
33
|
+
fields = record.fetch("fields")
|
|
34
|
+
case record["type"]
|
|
35
|
+
when "MouseDown"
|
|
36
|
+
target = locator(record["target"], fields.fetch("position"))
|
|
37
|
+
return fields["button"].to_s == "right" ? "#{target}.right_click" : "#{target}.click" if target
|
|
38
|
+
|
|
39
|
+
point = fields.fetch("position")
|
|
40
|
+
method = fields["button"].to_s == "right" ? "right_click" : "click"
|
|
41
|
+
"ui.#{method}([#{point.fetch('x')}, #{point.fetch('y')}])"
|
|
42
|
+
when "KeyDown" then "ui.press(#{fields.fetch('keystroke').dump})"
|
|
43
|
+
when "ScrollWheel"
|
|
44
|
+
point = fields.fetch("position")
|
|
45
|
+
delta = fields.fetch("delta")
|
|
46
|
+
"ui.scroll([#{point.fetch('x')}, #{point.fetch('y')}], dx: #{delta.fetch('x')}, dy: #{delta.fetch('y')})"
|
|
47
|
+
when "Composition" then "ui.compose(#{fields.fetch('text').dump}, selection: #{fields['selection'].inspect})"
|
|
48
|
+
when "FileDrop"
|
|
49
|
+
point = fields.fetch("position")
|
|
50
|
+
"ui.drop_files(#{fields.fetch('paths').inspect}, at: [#{point.fetch('x')}, #{point.fetch('y')}])"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def locator(target, point)
|
|
55
|
+
return "ui.find(test_id: #{target['test_id'].dump})" if target&.fetch("test_id", nil)
|
|
56
|
+
return "ui.button(#{target['text'].dump})" if target&.fetch("text", nil)&.then { |text| !text.empty? }
|
|
57
|
+
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def typing_key?(records, index)
|
|
62
|
+
records[index]["type"] == "KeyDown" && records[index + 1]&.fetch("type") == "TextInput"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def minitest(body)
|
|
66
|
+
<<~RUBY
|
|
67
|
+
require "syrma/minitest"
|
|
68
|
+
|
|
69
|
+
class RecordedUiTest < Minitest::Test
|
|
70
|
+
include Syrma::Minitest
|
|
71
|
+
|
|
72
|
+
def test_recorded_flow
|
|
73
|
+
#{body}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
RUBY
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def rspec(body)
|
|
80
|
+
<<~RUBY
|
|
81
|
+
require "syrma/rspec"
|
|
82
|
+
|
|
83
|
+
RSpec.describe "recorded UI", type: :zaniah do
|
|
84
|
+
it "replays the flow" do
|
|
85
|
+
#{body}
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
RUBY
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syrma
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :timeout, :strict, :event_frames, :text, :fonts, :snapshot_dir, :artifacts_dir
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@timeout = 2.0
|
|
9
|
+
@strict = true
|
|
10
|
+
@event_frames = :each
|
|
11
|
+
@text = :deterministic
|
|
12
|
+
@fonts = []
|
|
13
|
+
@snapshot_dir = "test/syrma_snapshots"
|
|
14
|
+
@artifacts_dir = "tmp/syrma"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Syrma
|
|
6
|
+
module Diagnostics
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def dir_for(test_class, test_name)
|
|
10
|
+
root = ENV["SYRMA_ARTIFACTS"] || Syrma.configuration.artifacts_dir
|
|
11
|
+
File.join(root, sanitize(test_class), sanitize(test_name))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def write(session, dir, message: nil)
|
|
15
|
+
FileUtils.mkdir_p(dir)
|
|
16
|
+
tree = session.tree
|
|
17
|
+
size = session.window.content_size
|
|
18
|
+
Zaniah::PNG.write(File.join(dir, "screenshot.png"), size.width.to_i, size.height.to_i, session.screenshot_pixels)
|
|
19
|
+
File.write(File.join(dir, "tree.txt"), tree.root ? TreeFormat.dump(tree.root) : "(nothing rendered)\n", encoding: "UTF-8")
|
|
20
|
+
File.write(File.join(dir, "hits.txt"), hits(tree), encoding: "UTF-8")
|
|
21
|
+
File.write(File.join(dir, "text_runs.txt"), text_runs(tree), encoding: "UTF-8")
|
|
22
|
+
File.write(File.join(dir, "events.log"), events(session), encoding: "UTF-8")
|
|
23
|
+
File.write(File.join(dir, "terminal.txt"), session.terminal_lines.join("\n") + "\n", encoding: "UTF-8") if session.tui?
|
|
24
|
+
File.write(File.join(dir, "summary.md"), summary(session, dir, message, tree, size), encoding: "UTF-8")
|
|
25
|
+
dir
|
|
26
|
+
rescue StandardError => error
|
|
27
|
+
warn "syrma: failed to write diagnostics: #{error.class}: #{error.message}"
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def sanitize(value) = value.to_s.gsub(/[^A-Za-z0-9_-]/, "_")
|
|
32
|
+
|
|
33
|
+
def hits(tree)
|
|
34
|
+
tree.hits.each_with_index.map do |(bounds, node), index|
|
|
35
|
+
"#{index}: [#{bounds.x},#{bounds.y} #{bounds.width}x#{bounds.height}] #{node ? node.inspect : '(unknown)'}"
|
|
36
|
+
end.join("\n") + "\n"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def text_runs(tree)
|
|
40
|
+
tree.text_runs.map { |x, y, text, color| "#{x},#{y} #{color} #{text.inspect}" }.join("\n") + "\n"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def events(session)
|
|
44
|
+
session.event_log.last(100).map do |event|
|
|
45
|
+
"frame=#{event.frame} window=#{event.window.inspect} #{event.input.inspect}"
|
|
46
|
+
end.join("\n") + "\n"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def summary(_session, dir, message, tree, size)
|
|
50
|
+
links = %w[screenshot.png tree.txt hits.txt text_runs.txt events.log terminal.txt expected.png actual.png diff.png]
|
|
51
|
+
.select { |name| File.exist?(File.join(dir, name)) }
|
|
52
|
+
.map { |name| "[#{name}](#{name})" }.join(" / ")
|
|
53
|
+
<<~MARKDOWN
|
|
54
|
+
# #{File.basename(dir)}
|
|
55
|
+
|
|
56
|
+
#{message}
|
|
57
|
+
|
|
58
|
+
- zaniah #{Zaniah::VERSION} / Ruby #{RUBY_VERSION} / #{RUBY_PLATFORM}
|
|
59
|
+
- window #{size.width}x#{size.height}, frame #{tree.frame}
|
|
60
|
+
- #{links}
|
|
61
|
+
MARKDOWN
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/syrma/errors.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syrma
|
|
4
|
+
class Error < Zaniah::Error; end
|
|
5
|
+
class UnstableUI < Error; end
|
|
6
|
+
class WaitTimeout < Error; end
|
|
7
|
+
class SnapshotMissing < Error; end
|
|
8
|
+
class SnapshotMismatch < Error; end
|
|
9
|
+
class UnsupportedBackend < Error; end
|
|
10
|
+
|
|
11
|
+
class ElementNotFound < Error
|
|
12
|
+
def initialize(locator, tree)
|
|
13
|
+
texts = tree.text_runs.map { |run| run[2] }.uniq.first(10)
|
|
14
|
+
super("Element not found: #{locator}\n Visible text: #{texts.inspect}")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class AmbiguousMatch < Error
|
|
19
|
+
def initialize(locator, nodes)
|
|
20
|
+
super("Matched #{nodes.length} elements: #{locator}\n " +
|
|
21
|
+
nodes.first(5).map(&:inspect).join("\n ") + "\n Refine with nth, first, or within")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
class NotActionable < Error
|
|
26
|
+
attr_reader :reason
|
|
27
|
+
|
|
28
|
+
def initialize(reason, message)
|
|
29
|
+
@reason = reason
|
|
30
|
+
super(message)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Syrma
|
|
6
|
+
module EventCodec
|
|
7
|
+
I = Zaniah::Input
|
|
8
|
+
TYPES = {
|
|
9
|
+
"MouseDown" => I::MouseDown,
|
|
10
|
+
"MouseUp" => I::MouseUp,
|
|
11
|
+
"MouseMove" => I::MouseMove,
|
|
12
|
+
"ScrollWheel" => I::ScrollWheel,
|
|
13
|
+
"KeyDown" => I::KeyDown,
|
|
14
|
+
"KeyUp" => I::KeyUp,
|
|
15
|
+
"TextInput" => I::TextInput,
|
|
16
|
+
"Composition" => I::Composition,
|
|
17
|
+
"FileDrop" => I::FileDrop
|
|
18
|
+
}.freeze
|
|
19
|
+
SYMBOLS = %i[button phase].freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def dump(event, t:, target: nil)
|
|
24
|
+
type = TYPES.key(event.class) or raise ArgumentError, "Unsupported event: #{event.class}"
|
|
25
|
+
fields = event.to_h.transform_values { |value| encode(value) }
|
|
26
|
+
JSON.generate({"t" => Float(t), "type" => type, "fields" => fields}.tap { |record| record["target"] = target if target })
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def load(line)
|
|
30
|
+
data = parse(line)
|
|
31
|
+
klass = TYPES.fetch(data["type"]) { raise ArgumentError, "Unknown event type: #{data['type'].inspect}" }
|
|
32
|
+
fields = data.fetch("fields")
|
|
33
|
+
values = klass.members.map do |member|
|
|
34
|
+
value = decode(fields.fetch(member.to_s))
|
|
35
|
+
SYMBOLS.include?(member) && value ? value.to_s.to_sym : value
|
|
36
|
+
end
|
|
37
|
+
klass.new(*values)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def parse(line)
|
|
41
|
+
data = JSON.parse(line)
|
|
42
|
+
raise ArgumentError, "Recording must be a JSON object" unless data.is_a?(Hash)
|
|
43
|
+
raise ArgumentError, "Unknown event type: #{data['type'].inspect}" unless TYPES.key?(data["type"])
|
|
44
|
+
raise ArgumentError, "Missing fields" unless data["fields"].is_a?(Hash)
|
|
45
|
+
|
|
46
|
+
data
|
|
47
|
+
rescue JSON::ParserError => error
|
|
48
|
+
raise ArgumentError, "Invalid JSON: #{error.message}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def encode(value)
|
|
52
|
+
return {"x" => value.x, "y" => value.y} if value.is_a?(Zaniah::Point)
|
|
53
|
+
|
|
54
|
+
value
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def decode(value)
|
|
58
|
+
return Zaniah::Point.new(value.fetch("x"), value.fetch("y")) if value.is_a?(Hash) && value.keys.sort == %w[x y]
|
|
59
|
+
|
|
60
|
+
value
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syrma
|
|
4
|
+
Event = Data.define(:frame, :window, :input)
|
|
5
|
+
|
|
6
|
+
class EventLog
|
|
7
|
+
include Enumerable
|
|
8
|
+
|
|
9
|
+
def initialize(limit: 100)
|
|
10
|
+
@limit = limit
|
|
11
|
+
@events = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def each(&block) = @events.each(&block)
|
|
15
|
+
def last(count = nil) = count ? @events.last(count) : @events.last
|
|
16
|
+
|
|
17
|
+
def add(frame, window, input)
|
|
18
|
+
@events.shift if @events.length >= @limit
|
|
19
|
+
@events << Event.new(frame: frame, window: window, input: input)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|