syrma 0.1.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +106 -11
- data/lib/syrma/instrumentation.rb +13 -4
- data/lib/syrma/internals.rb +9 -3
- data/lib/syrma/minitest.rb +36 -0
- data/lib/syrma/recording.rb +401 -0
- data/lib/syrma/rspec.rb +27 -0
- data/lib/syrma/session.rb +20 -0
- data/lib/syrma/ui_assertions.rb +51 -0
- data/lib/syrma/version.rb +1 -1
- data/lib/syrma/window_driver.rb +14 -4
- data/lib/syrma.rb +2 -0
- data/sig/syrma.rbs +38 -0
- metadata +41 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b76bd8cacebc9d29ee6b28a913efb6ef5ec7283cdf6462279064343207925a71
|
|
4
|
+
data.tar.gz: 5ee18a3987ecee83fd8c2a334afee51c1a161f3e892c155d0a9b1ecda3e0e68a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8166b829a204806d7c53c101bebab2b1568e95d2af3756510aa85eecf76a9234602b656a316388ce5f0c2f09b4e949b6ba18076aaabf8b2b9e2146ba69d88348
|
|
7
|
+
data.tar.gz: 11694f50f19407679166aa2cc305c1418832a3075166e76d9153cbd17b31996989f7211bd7b9997ba9f8818903a17147f5342196cd6d2ff4a4f7d9aa4ef6214f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 - 2026-09-20
|
|
4
|
+
|
|
5
|
+
- Add deterministic virtual-clock demo recording with humanized input, pixel-space chrome, and Wezen output hooks.
|
|
6
|
+
|
|
7
|
+
## 0.2.0 - 2026-09-15
|
|
8
|
+
|
|
9
|
+
- Add Minitest assertions and RSpec matchers for instrumented panels and editor decorations.
|
|
10
|
+
- Support Zaniah 0.2 through 0.5 in headless instrumentation.
|
|
11
|
+
|
|
3
12
|
## 0.1.0 - 2026-09-11
|
|
4
13
|
|
|
5
14
|
- Initial release.
|
data/README.md
CHANGED
|
@@ -1,10 +1,44 @@
|
|
|
1
|
-
|
|
1
|
+
<h1 align="center">Syrma</h1>
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<strong>Drive and assert <a href="https://github.com/noxdea/zaniah">Zaniah</a> GUI and TUI applications from Ruby tests.</strong>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://rubygems.org/gems/syrma"><img src="https://img.shields.io/gem/v/syrma.svg" alt="Gem version"></a>
|
|
9
|
+
<a href="https://github.com/noxdea/syrma/actions/workflows/main.yml"><img src="https://github.com/noxdea/syrma/actions/workflows/main.yml/badge.svg" alt="CI"></a>
|
|
10
|
+
<a href="https://www.ruby-lang.org/"><img src="https://img.shields.io/badge/ruby-3.1%2B-CC342D.svg" alt="Ruby 3.1+"></a>
|
|
11
|
+
<a href="LICENSE.txt"><img src="https://img.shields.io/github/license/noxdea/syrma.svg" alt="MIT License"></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<a href="https://noxdea.github.io/syrma/">Website</a> ·
|
|
16
|
+
<a href="#features">Features</a> ·
|
|
17
|
+
<a href="#installation">Installation</a> ·
|
|
18
|
+
<a href="#quick-start">Quick Start</a> ·
|
|
19
|
+
<a href="#snapshots-and-diagnostics">Snapshots</a> ·
|
|
20
|
+
<a href="#documentation">Documentation</a>
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
Syrma locates rendered elements, sends input through Zaniah's real event path, waits for redraws, and compares the observable result. Tests can inspect text, element trees, terminal output, pixels, screenshots, menus, and tooltips without bypassing application input handling.
|
|
26
|
+
|
|
27
|
+

|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- Drive GUI and TUI sessions with pointer, keyboard, clipboard, composition, file-drop, resize, and terminal input
|
|
32
|
+
- Locate elements by text, test ID, actionability, or nested queries resolved against the latest frame
|
|
33
|
+
- Assert semantic output first, with tree, terminal, pixel, and screenshot snapshots when needed
|
|
34
|
+
- Keep timing deterministic with a session-scoped virtual clock and explicit frame settling
|
|
35
|
+
- Test with Minitest assertions or RSpec matchers
|
|
36
|
+
- Capture screenshots, hit regions, text runs, and recent events when a test fails
|
|
37
|
+
- Record deterministic APNG/GIF frames and asciinema events from the virtual clock
|
|
4
38
|
|
|
5
39
|
## Installation
|
|
6
40
|
|
|
7
|
-
Add Syrma to the test group in your `Gemfile`:
|
|
41
|
+
Add Syrma and your test framework to the test group in your `Gemfile`:
|
|
8
42
|
|
|
9
43
|
```ruby
|
|
10
44
|
group :test do
|
|
@@ -13,11 +47,35 @@ group :test do
|
|
|
13
47
|
end
|
|
14
48
|
```
|
|
15
49
|
|
|
16
|
-
Then
|
|
50
|
+
Then install the bundle and verify the test environment:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
bundle install
|
|
54
|
+
bundle exec syrma doctor
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Syrma requires Ruby 3.1 or later and supports Zaniah `>= 0.2, < 0.6`. RSpec users can replace Minitest with RSpec in the test group.
|
|
58
|
+
|
|
59
|
+
### Demo recording
|
|
60
|
+
|
|
61
|
+
Install `wezen` alongside Syrma to export a virtual-clock recording:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
result = session.record(fps: 12, seed: 42) do |recording|
|
|
65
|
+
recording.cursor
|
|
66
|
+
recording.caption("Open the file")
|
|
67
|
+
recording.type_humanly("README.md")
|
|
68
|
+
recording.pause(1.0)
|
|
69
|
+
end
|
|
70
|
+
result.write_apng("docs/media/overview.apng")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Recordings require a virtual clock and headless session for repeatable pixels.
|
|
74
|
+
Use `session.record_cast` with a TUI session for asciinema v2 output.
|
|
17
75
|
|
|
18
|
-
##
|
|
76
|
+
## Quick start
|
|
19
77
|
|
|
20
|
-
Keep window creation separate from the code that mounts
|
|
78
|
+
Keep window creation separate from the code that mounts the interface:
|
|
21
79
|
|
|
22
80
|
```ruby
|
|
23
81
|
module Counter
|
|
@@ -32,6 +90,8 @@ module Counter
|
|
|
32
90
|
end
|
|
33
91
|
```
|
|
34
92
|
|
|
93
|
+
### Minitest
|
|
94
|
+
|
|
35
95
|
```ruby
|
|
36
96
|
require "syrma/minitest"
|
|
37
97
|
|
|
@@ -43,12 +103,27 @@ class CounterTest < Minitest::Test
|
|
|
43
103
|
end
|
|
44
104
|
|
|
45
105
|
def test_increment
|
|
46
|
-
ui.
|
|
106
|
+
ui.test_id("increment").click
|
|
47
107
|
assert_ui_text "Count: 1"
|
|
48
108
|
end
|
|
49
109
|
end
|
|
50
110
|
```
|
|
51
111
|
|
|
112
|
+
### RSpec
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
require "syrma/rspec"
|
|
116
|
+
|
|
117
|
+
RSpec.describe "Counter", type: :zaniah do
|
|
118
|
+
before { zaniah_session(width: 320, height: 200) { |window| Counter.mount(window) } }
|
|
119
|
+
|
|
120
|
+
it "increments the count" do
|
|
121
|
+
ui.test_id("increment").click
|
|
122
|
+
expect(ui).to have_ui_text("Count: 1")
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
```
|
|
126
|
+
|
|
52
127
|
`test_id` is provided by Zaniah 0.2 and is safe to use in application code.
|
|
53
128
|
|
|
54
129
|
## API at a glance
|
|
@@ -61,10 +136,22 @@ end
|
|
|
61
136
|
| Window/input | `resize`, `close`, `drop_files`, `feed_terminal` |
|
|
62
137
|
| Synchronize | `settle`, `wait_for`, `advance` |
|
|
63
138
|
| Inspect | `tree`, `texts`, `at`, `pixel`, `screenshot`, `terminal_lines`, `menu`, `tooltip` |
|
|
64
|
-
| Assert |
|
|
139
|
+
| Assert | Text, element, visibility, panels, decorations, background, pixel, tooltip, menu, tree/terminal/image snapshots |
|
|
65
140
|
|
|
66
141
|
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
142
|
|
|
143
|
+
Panel and decoration assertions use rendered `test_id` instrumentation, so application code does not need a Syrma or Canopus runtime dependency:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
assert_panel_visible :problems
|
|
147
|
+
assert_panel_badge :problems, 3
|
|
148
|
+
assert_inline_overlay line: 10, text: ": String"
|
|
149
|
+
assert_gutter_marker line: 5, kind: :breakpoint
|
|
150
|
+
assert_line_highlight line: 12, kind: :debug_position
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The corresponding IDs are `syrma:panel:problems`, `syrma:panel:problems:badge`, `syrma:decoration:inline:10`, `syrma:decoration:gutter:5:breakpoint`, and `syrma:decoration:line:12:debug_position`. Lines are zero-based. Instrument the element that was actually laid out and painted; assertions require positive visible bounds. RSpec provides the same names with `have_` in place of `assert_`.
|
|
154
|
+
|
|
68
155
|
## Snapshots and diagnostics
|
|
69
156
|
|
|
70
157
|
```ruby
|
|
@@ -81,24 +168,32 @@ bundle exec syrma snapshots prune --dry-run
|
|
|
81
168
|
bundle exec syrma report
|
|
82
169
|
```
|
|
83
170
|
|
|
84
|
-
## Determinism and
|
|
171
|
+
## Determinism and performance
|
|
85
172
|
|
|
86
173
|
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
174
|
|
|
88
175
|
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
176
|
|
|
90
|
-
|
|
177
|
+
## Documentation
|
|
178
|
+
|
|
179
|
+
- [Guide](docs/guide.md): application structure, sessions, CI, configuration, and troubleshooting
|
|
180
|
+
- [Recipes](docs/recipes.md): interactions, screenshots, TUI sessions, and multiple windows
|
|
181
|
+
- [Changelog](CHANGELOG.md): release history
|
|
91
182
|
|
|
92
183
|
## Development
|
|
93
184
|
|
|
94
185
|
```sh
|
|
186
|
+
bundle install
|
|
95
187
|
bundle exec rake
|
|
188
|
+
bundle exec rake demo
|
|
96
189
|
bundle exec ruby -Ilib:test script/test_gesture.rb
|
|
97
190
|
bundle exec rbs -I sig validate
|
|
98
191
|
gem build --strict syrma.gemspec
|
|
99
192
|
```
|
|
100
193
|
|
|
101
|
-
|
|
194
|
+
## Contributing
|
|
195
|
+
|
|
196
|
+
Bug reports and pull requests are welcome on [GitHub](https://github.com/noxdea/syrma).
|
|
102
197
|
|
|
103
198
|
## License
|
|
104
199
|
|
|
@@ -6,7 +6,7 @@ module Syrma
|
|
|
6
6
|
|
|
7
7
|
module WindowCapture
|
|
8
8
|
attr_reader :testing_root, :testing_clear, :testing_frame
|
|
9
|
-
attr_accessor :testing_lazy_raster
|
|
9
|
+
attr_accessor :testing_lazy_raster, :testing_reduce_motion
|
|
10
10
|
|
|
11
11
|
def on_frame(&callback)
|
|
12
12
|
@testing_on_frame = callback
|
|
@@ -29,8 +29,15 @@ module Syrma
|
|
|
29
29
|
@testing_recorder = recorder
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
def render(element, present: true, **options)
|
|
33
|
-
|
|
32
|
+
def render(element, present: true, **options) = super(element, present: present && !testing_lazy_raster, **options)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
module FrameContextCapture
|
|
36
|
+
def theme
|
|
37
|
+
current = super
|
|
38
|
+
return current unless window.respond_to?(:testing_reduce_motion) && window.testing_reduce_motion
|
|
39
|
+
|
|
40
|
+
current.with(motion: current.motion.with(reduced: true))
|
|
34
41
|
end
|
|
35
42
|
end
|
|
36
43
|
|
|
@@ -56,6 +63,7 @@ module Syrma
|
|
|
56
63
|
return if @installed
|
|
57
64
|
|
|
58
65
|
Zaniah::Platform.singleton_class.prepend(BackendOverride)
|
|
66
|
+
Zaniah::FrameContext.prepend(FrameContextCapture) if Zaniah::FrameContext.method_defined?(:theme)
|
|
59
67
|
@installed = true
|
|
60
68
|
end
|
|
61
69
|
|
|
@@ -69,9 +77,10 @@ module Syrma
|
|
|
69
77
|
Thread.current[:syrma_window_capture] = previous
|
|
70
78
|
end
|
|
71
79
|
|
|
72
|
-
def capture(window, lazy_raster:)
|
|
80
|
+
def capture(window, lazy_raster:, reduce_motion:)
|
|
73
81
|
window.extend(WindowCapture) unless window.is_a?(WindowCapture)
|
|
74
82
|
window.testing_lazy_raster = lazy_raster
|
|
83
|
+
window.testing_reduce_motion = reduce_motion
|
|
75
84
|
window.on_frame
|
|
76
85
|
window
|
|
77
86
|
end
|
data/lib/syrma/internals.rb
CHANGED
|
@@ -5,9 +5,9 @@ module Syrma
|
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
7
|
def style(element) = element.instance_variable_get(:@style) || {}
|
|
8
|
-
def background(element) = element
|
|
9
|
-
def border_color(element) = element
|
|
10
|
-
def radius(element) = element
|
|
8
|
+
def background(element) = paint_value(element, :background, :@background)
|
|
9
|
+
def border_color(element) = paint_value(element, :border_color, :@border_color)
|
|
10
|
+
def radius(element) = paint_value(element, :corner_radii, :@radius)
|
|
11
11
|
def tooltip(element) = element.instance_variable_get(:@tooltip)
|
|
12
12
|
def context_menu(element) = element.instance_variable_get(:@context_menu)
|
|
13
13
|
def key(element) = element.instance_variable_get(:@key)
|
|
@@ -22,5 +22,11 @@ module Syrma
|
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def executor_idle?(executor) = executor.instance_variable_get(:@foreground).empty?
|
|
25
|
+
|
|
26
|
+
def paint_value(element, property, legacy)
|
|
27
|
+
resolved = element.respond_to?(:resolved_style) ? element.resolved_style : nil
|
|
28
|
+
resolved&.[](property) || element.instance_variable_get(legacy) || style(element)[property]
|
|
29
|
+
end
|
|
30
|
+
private_class_method :paint_value
|
|
25
31
|
end
|
|
26
32
|
end
|
data/lib/syrma/minitest.rb
CHANGED
|
@@ -94,6 +94,35 @@ module Syrma
|
|
|
94
94
|
end
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
+
def assert_panel_visible(id, session: ui, timeout: session.timeout)
|
|
98
|
+
test_id = UIAssertions.panel_id(id)
|
|
99
|
+
assert_ui_instrumentation(session, timeout, test_id, "panel #{id.inspect} to be visible", "#{UIAssertions::PREFIX}:panel:")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def assert_panel_badge(id, value, session: ui, timeout: session.timeout)
|
|
103
|
+
test_id = UIAssertions.panel_id(id, badge: true)
|
|
104
|
+
assert_ui_instrumentation(session, timeout, test_id, "panel #{id.inspect} badge to be #{value.inspect}",
|
|
105
|
+
"#{UIAssertions::PREFIX}:panel:", text: value)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def assert_inline_overlay(line:, text:, session: ui, timeout: session.timeout)
|
|
109
|
+
test_id = UIAssertions.decoration_id(:inline, line)
|
|
110
|
+
assert_ui_instrumentation(session, timeout, test_id, "inline overlay on line #{line} with text #{text.inspect}",
|
|
111
|
+
"#{UIAssertions::PREFIX}:decoration:inline:", text: text)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def assert_gutter_marker(line:, kind:, session: ui, timeout: session.timeout)
|
|
115
|
+
test_id = UIAssertions.decoration_id(:gutter, line, kind)
|
|
116
|
+
assert_ui_instrumentation(session, timeout, test_id, "#{kind.inspect} gutter marker on line #{line}",
|
|
117
|
+
"#{UIAssertions::PREFIX}:decoration:gutter:")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def assert_line_highlight(line:, kind:, session: ui, timeout: session.timeout)
|
|
121
|
+
test_id = UIAssertions.decoration_id(:line, line, kind)
|
|
122
|
+
assert_ui_instrumentation(session, timeout, test_id, "#{kind.inspect} line highlight on line #{line}",
|
|
123
|
+
"#{UIAssertions::PREFIX}:decoration:line:")
|
|
124
|
+
end
|
|
125
|
+
|
|
97
126
|
def assert_screenshot(name, session: ui, **options)
|
|
98
127
|
warn "syrma: text: :none does not render text in images" if session.text_mode == :none
|
|
99
128
|
store = Snapshots::Store.new
|
|
@@ -154,6 +183,13 @@ module Syrma
|
|
|
154
183
|
flunk error.message
|
|
155
184
|
end
|
|
156
185
|
|
|
186
|
+
def assert_ui_instrumentation(session, timeout, test_id, expectation, prefix, text: UIAssertions::ANY_TEXT)
|
|
187
|
+
ui_eventually(session, timeout,
|
|
188
|
+
-> { "Expected #{expectation}; observed #{UIAssertions.describe(session, prefix)}" }) do
|
|
189
|
+
UIAssertions.visible?(session, test_id, text: text)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
157
193
|
def snapshot_path(store, name, extension)
|
|
158
194
|
test_file = self.class.instance_method(self.name.to_sym).source_location&.first || "unknown"
|
|
159
195
|
store.path(test_file: test_file, test_class: self.class.name, name: name, ext: extension)
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syrma
|
|
4
|
+
class Recording
|
|
5
|
+
FONT = {
|
|
6
|
+
"A" => %w[01110 10001 10001 11111 10001 10001 10001], "B" => %w[11110 10001 10001 11110 10001 10001 11110],
|
|
7
|
+
"C" => %w[01111 10000 10000 10000 10000 10000 01111], "D" => %w[11110 10001 10001 10001 10001 10001 11110],
|
|
8
|
+
"E" => %w[11111 10000 10000 11110 10000 10000 11111], "F" => %w[11111 10000 10000 11110 10000 10000 10000],
|
|
9
|
+
"G" => %w[01111 10000 10000 10111 10001 10001 01111], "H" => %w[10001 10001 10001 11111 10001 10001 10001],
|
|
10
|
+
"I" => %w[11111 00100 00100 00100 00100 00100 11111], "J" => %w[00111 00010 00010 00010 10010 10010 01100],
|
|
11
|
+
"K" => %w[10001 10010 10100 11000 10100 10010 10001], "L" => %w[10000 10000 10000 10000 10000 10000 11111],
|
|
12
|
+
"M" => %w[10001 11011 10101 10101 10001 10001 10001], "N" => %w[10001 11001 10101 10011 10001 10001 10001],
|
|
13
|
+
"O" => %w[01110 10001 10001 10001 10001 10001 01110], "P" => %w[11110 10001 10001 11110 10000 10000 10000],
|
|
14
|
+
"Q" => %w[01110 10001 10001 10001 10101 10010 01101], "R" => %w[11110 10001 10001 11110 10100 10010 10001],
|
|
15
|
+
"S" => %w[01111 10000 10000 01110 00001 00001 11110], "T" => %w[11111 00100 00100 00100 00100 00100 00100],
|
|
16
|
+
"U" => %w[10001 10001 10001 10001 10001 10001 01110], "V" => %w[10001 10001 10001 10001 10001 01010 00100],
|
|
17
|
+
"W" => %w[10001 10001 10001 10101 10101 11011 10001], "X" => %w[10001 10001 01010 00100 01010 10001 10001],
|
|
18
|
+
"Y" => %w[10001 10001 01010 00100 00100 00100 00100], "Z" => %w[11111 00001 00010 00100 01000 10000 11111],
|
|
19
|
+
"0" => %w[01110 10001 10011 10101 11001 10001 01110], "1" => %w[00100 01100 00100 00100 00100 00100 01110],
|
|
20
|
+
"2" => %w[01110 10001 00001 00010 00100 01000 11111], "3" => %w[11110 00001 00001 01110 00001 00001 11110],
|
|
21
|
+
"4" => %w[00010 00110 01010 10010 11111 00010 00010], "5" => %w[11111 10000 10000 11110 00001 00001 11110],
|
|
22
|
+
"6" => %w[01110 10000 10000 11110 10001 10001 01110], "7" => %w[11111 00001 00010 00100 01000 01000 01000],
|
|
23
|
+
"8" => %w[01110 10001 10001 01110 10001 10001 01110], "9" => %w[01110 10001 10001 01111 00001 00001 01110],
|
|
24
|
+
"." => %w[00000 00000 00000 00000 00000 00110 00110], "-" => %w[00000 00000 00000 01110 00000 00000 00000],
|
|
25
|
+
":" => %w[00000 00110 00110 00000 00110 00110 00000], "/" => %w[00001 00010 00010 00100 01000 01000 10000]
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
Cast = Data.define(:width, :height, :events)
|
|
29
|
+
Result = Data.define(:animation, :cast, :frames, :duration_ms) do
|
|
30
|
+
def write_apng(path, **options)
|
|
31
|
+
require "wezen"
|
|
32
|
+
Wezen::APNG.write(path, animation, **options)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def write_gif(path, **options)
|
|
36
|
+
require "wezen"
|
|
37
|
+
Wezen::GIF.write(path, animation, **options)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def write_png(path)
|
|
41
|
+
raise Error, "recording has no animation" unless animation
|
|
42
|
+
frame = frames.last || raise(Error, "recording has no frames")
|
|
43
|
+
Zaniah::PNG.write(path, animation.width, animation.height, frame.rgba)
|
|
44
|
+
path
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def write_cast(path, **options)
|
|
48
|
+
require "wezen"
|
|
49
|
+
source = cast.is_a?(Cast) ? cast : nil
|
|
50
|
+
raise Error, "recording has no cast dimensions" unless source
|
|
51
|
+
Wezen::Cast.write(path, width: source.width, height: source.height, events: source.events, **options)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
Frame = Data.define(:rgba, :delay_ms)
|
|
55
|
+
|
|
56
|
+
attr_reader :session, :fps, :scale, :max_frames
|
|
57
|
+
|
|
58
|
+
def initialize(session, fps:, scale:, max_frames:, seed: nil, chrome: {}, cast_only: false)
|
|
59
|
+
@session = session
|
|
60
|
+
@fps = Float(fps)
|
|
61
|
+
@scale = Float(scale)
|
|
62
|
+
@max_frames = Integer(max_frames)
|
|
63
|
+
raise ArgumentError, "fps must be positive" unless @fps.positive?
|
|
64
|
+
raise ArgumentError, "scale must be positive" unless @scale.positive?
|
|
65
|
+
raise ArgumentError, "max_frames must be positive" unless @max_frames.positive?
|
|
66
|
+
|
|
67
|
+
@random = Random.new(seed.nil? ? 0 : Integer(seed))
|
|
68
|
+
@cast_only = cast_only
|
|
69
|
+
@frames = []
|
|
70
|
+
@captures = 0
|
|
71
|
+
@events = []
|
|
72
|
+
@elapsed = 0.0
|
|
73
|
+
@frame_remainder = 0.0
|
|
74
|
+
@cursor_visible = chrome.fetch(:cursor, false)
|
|
75
|
+
@keycaps_visible = chrome.fetch(:keycaps, false)
|
|
76
|
+
@cursor_style = :arrow
|
|
77
|
+
@cursor = [0, 0]
|
|
78
|
+
@keycap = nil
|
|
79
|
+
@keycap_until = 0.0
|
|
80
|
+
@caption_text = nil
|
|
81
|
+
@caption_until = 0.0
|
|
82
|
+
@highlight_bounds = nil
|
|
83
|
+
@highlight_until = 0.0
|
|
84
|
+
@zoom = nil
|
|
85
|
+
@zoom_until = 0.0
|
|
86
|
+
@last_output = ""
|
|
87
|
+
@events << event(0.0, :resize, "#{terminal_dimensions[0]}x#{terminal_dimensions[1]}") if @cast_only
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def advance(seconds)
|
|
91
|
+
seconds = Float(seconds)
|
|
92
|
+
raise ArgumentError, "time cannot be negative" if seconds.negative?
|
|
93
|
+
|
|
94
|
+
interval = 1.0 / @fps
|
|
95
|
+
remaining = seconds
|
|
96
|
+
while remaining.positive? && @frame_remainder + remaining >= interval
|
|
97
|
+
step = interval - @frame_remainder
|
|
98
|
+
session.advance(step)
|
|
99
|
+
@elapsed += step
|
|
100
|
+
remaining -= step
|
|
101
|
+
@frame_remainder = 0.0
|
|
102
|
+
capture
|
|
103
|
+
end
|
|
104
|
+
if remaining.positive?
|
|
105
|
+
session.advance(remaining)
|
|
106
|
+
@elapsed += remaining
|
|
107
|
+
@frame_remainder += remaining
|
|
108
|
+
end
|
|
109
|
+
self
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def pause(seconds)
|
|
113
|
+
advance(seconds)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def frame(count: 1)
|
|
117
|
+
Integer(count).times { capture }
|
|
118
|
+
self
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def type_humanly(text, cps: 18, jitter: 0.25)
|
|
122
|
+
cps = Float(cps); jitter = Float(jitter)
|
|
123
|
+
raise ArgumentError, "cps must be positive" unless cps.positive?
|
|
124
|
+
raise ArgumentError, "jitter must be between 0 and 1" unless jitter.between?(0.0, 1.0)
|
|
125
|
+
|
|
126
|
+
text.each_grapheme_cluster do |character|
|
|
127
|
+
session.driver.type(character)
|
|
128
|
+
@keycap = character
|
|
129
|
+
@keycap_until = @elapsed + (@keycap_duration || 1.2)
|
|
130
|
+
advance((1.0 / cps) * (1.0 + (@random.rand * 2.0 - 1.0) * jitter))
|
|
131
|
+
end
|
|
132
|
+
self
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def press_slowly(*keystrokes, hold: 0.08, gap: 0.25)
|
|
136
|
+
keystrokes.each do |keystroke|
|
|
137
|
+
session.driver.press(keystroke)
|
|
138
|
+
@keycap = keystroke.to_s
|
|
139
|
+
@keycap_until = @elapsed + (@keycap_duration || 1.2)
|
|
140
|
+
advance(hold)
|
|
141
|
+
advance(gap)
|
|
142
|
+
end
|
|
143
|
+
self
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def click_slowly(target, approach: 0.4, **options)
|
|
147
|
+
point = point_for(target)
|
|
148
|
+
session.driver.mouse_move(point)
|
|
149
|
+
@cursor = [point.x, point.y]
|
|
150
|
+
advance(approach)
|
|
151
|
+
session.driver.click(target, **options)
|
|
152
|
+
self
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def drag_slowly(from, to, duration: 0.8)
|
|
156
|
+
start = point_for(from); finish = point_for(to)
|
|
157
|
+
steps = [(Float(duration) * @fps).round, 1].max
|
|
158
|
+
session.driver.mouse_move(start)
|
|
159
|
+
session.driver.mouse_down(start)
|
|
160
|
+
steps.times do |index|
|
|
161
|
+
ratio = (index + 1).fdiv(steps)
|
|
162
|
+
point = Zaniah::Point.new(start.x + (finish.x - start.x) * ratio, start.y + (finish.y - start.y) * ratio)
|
|
163
|
+
session.driver.mouse_move(point)
|
|
164
|
+
@cursor = [point.x, point.y]
|
|
165
|
+
advance(1.0 / @fps)
|
|
166
|
+
end
|
|
167
|
+
session.driver.mouse_up(finish)
|
|
168
|
+
self
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def scroll_smoothly(target, dy:, duration: 0.6)
|
|
172
|
+
steps = [(Float(duration) * @fps).round, 1].max
|
|
173
|
+
steps.times { session.driver.scroll(target, dy: Float(dy) / steps); advance(1.0 / @fps) }
|
|
174
|
+
self
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def cursor(visible: true, style: :arrow)
|
|
178
|
+
@cursor_visible = !!visible
|
|
179
|
+
@cursor_style = style.to_sym
|
|
180
|
+
self
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def keycaps(visible: true, duration: 1.2)
|
|
184
|
+
@keycaps_visible = !!visible
|
|
185
|
+
@keycap_duration = Float(duration)
|
|
186
|
+
self
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def highlight(target, padding: 8, duration: 1.0)
|
|
190
|
+
node = target.respond_to?(:resolve) ? target.resolve : target
|
|
191
|
+
bounds = node.respond_to?(:bounds) ? node.bounds : node
|
|
192
|
+
@highlight_bounds = [bounds.x - padding, bounds.y - padding, bounds.width + padding * 2, bounds.height + padding * 2]
|
|
193
|
+
@highlight_until = @elapsed + Float(duration)
|
|
194
|
+
self
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def caption(text, duration: 2.0, position: :bottom)
|
|
198
|
+
@caption_text = text.to_s
|
|
199
|
+
@caption_position = position.to_sym
|
|
200
|
+
@caption_until = @elapsed + Float(duration)
|
|
201
|
+
self
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def zoom(_target, scale: 2.0, duration: 0.6)
|
|
205
|
+
factor = Float(scale)
|
|
206
|
+
raise ArgumentError, "zoom scale must be positive" unless factor.positive?
|
|
207
|
+
node = _target.respond_to?(:resolve) ? _target.resolve : _target
|
|
208
|
+
bounds = node.respond_to?(:bounds) ? node.bounds : node
|
|
209
|
+
@zoom = [bounds.x + bounds.width / 2.0, bounds.y + bounds.height / 2.0, factor]
|
|
210
|
+
@zoom_until = @elapsed + Float(duration)
|
|
211
|
+
advance(duration)
|
|
212
|
+
self
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def result
|
|
216
|
+
animation = wezen_available? ? build_animation : nil
|
|
217
|
+
last_event_time = @events.last&.respond_to?(:time) ? @events.last.time : @events.last&.first
|
|
218
|
+
if @cast_only && last_event_time && last_event_time.to_f < @elapsed
|
|
219
|
+
@events << event(@elapsed, :output, "")
|
|
220
|
+
end
|
|
221
|
+
cast = @cast_only ? Cast.new(terminal_dimensions[0], terminal_dimensions[1], @events.freeze) : @events.freeze
|
|
222
|
+
Result.new(animation, cast, @frames.freeze, (@elapsed * 1000).round)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def write_apng(path, **options)
|
|
226
|
+
require_wezen
|
|
227
|
+
Wezen::APNG.write(path, build_animation, **options)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def write_gif(path, **options)
|
|
231
|
+
require_wezen
|
|
232
|
+
Wezen::GIF.write(path, build_animation, **options)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def write_png(path)
|
|
236
|
+
frame = @frames.last || raise(Error, "recording has no frames")
|
|
237
|
+
width, height = dimensions
|
|
238
|
+
Zaniah::PNG.write(path, width, height, frame.rgba)
|
|
239
|
+
path
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def write_cast(path, **options)
|
|
243
|
+
require_wezen
|
|
244
|
+
width, height = terminal_dimensions
|
|
245
|
+
Wezen::Cast.write(path, width: width, height: height, events: @events, **options)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
private
|
|
249
|
+
|
|
250
|
+
def require_wezen
|
|
251
|
+
require "wezen"
|
|
252
|
+
rescue LoadError => error
|
|
253
|
+
raise LoadError, "write_apng/write_gif/write_cast require the wezen gem (#{error.message})"
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def wezen_available?
|
|
257
|
+
require "wezen"
|
|
258
|
+
true
|
|
259
|
+
rescue LoadError
|
|
260
|
+
false
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def capture
|
|
264
|
+
raise Error, "record exceeded max_frames=#{@max_frames}" if @captures >= @max_frames
|
|
265
|
+
@captures += 1
|
|
266
|
+
if @cast_only
|
|
267
|
+
collect_output
|
|
268
|
+
return
|
|
269
|
+
end
|
|
270
|
+
pixels = session.screenshot_pixels
|
|
271
|
+
width, height = raw_dimensions
|
|
272
|
+
if @scale != 1.0
|
|
273
|
+
require_wezen
|
|
274
|
+
source_width, source_height = width, height
|
|
275
|
+
target_width = [(@scale * source_width).round, 1].max
|
|
276
|
+
target_height = [(@scale * source_height).round, 1].max
|
|
277
|
+
pixels = Wezen::Image.scale(pixels, source_width, source_height, to_width: target_width, to_height: target_height)
|
|
278
|
+
width = target_width; height = target_height
|
|
279
|
+
end
|
|
280
|
+
pixels = compose(pixels, width, height)
|
|
281
|
+
delay = (1000.0 / @fps).round
|
|
282
|
+
if (last = @frames.last) && last.rgba == pixels
|
|
283
|
+
# Keep one immutable buffer for a static interval; only its duration grows.
|
|
284
|
+
@frames[-1] = Frame.new(last.rgba, last.delay_ms + delay)
|
|
285
|
+
else
|
|
286
|
+
@frames << Frame.new(pixels.freeze, delay)
|
|
287
|
+
end
|
|
288
|
+
collect_output
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def build_animation
|
|
292
|
+
require_wezen
|
|
293
|
+
animation = Wezen::Animation.new(width: dimensions.first, height: dimensions.last)
|
|
294
|
+
@frames.each { |frame| animation.add(frame.rgba, delay_ms: frame.delay_ms) }
|
|
295
|
+
animation
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def dimensions
|
|
299
|
+
raw_dimensions.map { |value| [(@scale * value).round, 1].max }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def raw_dimensions
|
|
303
|
+
size = session.driver.window.content_size
|
|
304
|
+
[size.width.to_i, size.height.to_i]
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def terminal_dimensions
|
|
308
|
+
size = session.driver.window.content_size
|
|
309
|
+
[size.width.to_i, size.height.to_i]
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def point_for(target)
|
|
313
|
+
target = target.resolve if target.respond_to?(:resolve)
|
|
314
|
+
return target if target.is_a?(Zaniah::Point)
|
|
315
|
+
return Zaniah::Point.new(*target) if target.is_a?(Array) && target.length == 2
|
|
316
|
+
target.center
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def collect_output
|
|
320
|
+
return unless session.tui?
|
|
321
|
+
output = session.output_for.string
|
|
322
|
+
delta = output.byteslice(@last_output.bytesize..)
|
|
323
|
+
@events << event(@elapsed, :output, delta) if delta && !delta.empty?
|
|
324
|
+
@last_output = output
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def event(time, kind, data)
|
|
328
|
+
if defined?(Wezen::Cast::Event)
|
|
329
|
+
Wezen::Cast::Event.new(time, kind, data)
|
|
330
|
+
else
|
|
331
|
+
[time, kind, data]
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def compose(pixels, width, height)
|
|
336
|
+
pixels = zoom_pixels(pixels, width, height) if @zoom && @elapsed < @zoom_until
|
|
337
|
+
output = pixels.dup
|
|
338
|
+
if @highlight_bounds && @elapsed < @highlight_until
|
|
339
|
+
x, y, w, h = @highlight_bounds
|
|
340
|
+
height.times do |row|
|
|
341
|
+
width.times do |column|
|
|
342
|
+
next if column.between?(x, x + w - 1) && row.between?(y, y + h - 1)
|
|
343
|
+
index = (row * width + column) * 4
|
|
344
|
+
red, green, blue, alpha = output.byteslice(index, 4).bytes
|
|
345
|
+
output[index, 4] = [(red * 0.35).round, (green * 0.35).round, (blue * 0.35).round, alpha].pack("C4")
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
rect(output, width, height, x, y, w, h, [255, 192, 0, 255])
|
|
349
|
+
end
|
|
350
|
+
rect(output, width, height, @cursor[0], @cursor[1], 2, 2, [255, 255, 255, 255, 255]) if @cursor_visible
|
|
351
|
+
if @keycaps_visible && @keycap && @elapsed < @keycap_until
|
|
352
|
+
rect(output, width, height, 12, height - 42, [@keycap.to_s.length * 8 + 20, 28].max, 28, [30, 30, 30, 220])
|
|
353
|
+
draw_text(output, width, height, @keycap.to_s, 20, height - 35, [255, 255, 255, 255], scale: 2)
|
|
354
|
+
end
|
|
355
|
+
if @caption_text && @elapsed < @caption_until
|
|
356
|
+
y = @caption_position == :top ? 0 : height - 48
|
|
357
|
+
rect(output, width, height, 0, y, width, 48, [0, 0, 0, 190])
|
|
358
|
+
draw_text(output, width, height, @caption_text, 12, y + 16, [255, 255, 255, 255], scale: 1)
|
|
359
|
+
end
|
|
360
|
+
output
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def zoom_pixels(pixels, width, height)
|
|
364
|
+
center_x, center_y, factor = @zoom
|
|
365
|
+
output = String.new(capacity: pixels.bytesize, encoding: Encoding::BINARY)
|
|
366
|
+
height.times do |row|
|
|
367
|
+
source_y = (center_y + (row - height / 2.0) / factor).round
|
|
368
|
+
width.times do |column|
|
|
369
|
+
source_x = (center_x + (column - width / 2.0) / factor).round
|
|
370
|
+
output << if source_x.between?(0, width - 1) && source_y.between?(0, height - 1)
|
|
371
|
+
pixels.byteslice((source_y * width + source_x) * 4, 4)
|
|
372
|
+
else
|
|
373
|
+
"\0\0\0\0".b
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
output
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def rect(pixels, width, height, x, y, rect_width, rect_height, color)
|
|
381
|
+
x = x.to_i; y = y.to_i; rect_width = rect_width.to_i; rect_height = rect_height.to_i
|
|
382
|
+
y0 = [y, 0].max; y1 = [y + rect_height, height].min; x0 = [x, 0].max; x1 = [x + rect_width, width].min
|
|
383
|
+
(y0...y1).each { |row| (x0...x1).each { |column| pixels[(row * width + column) * 4, 4] = color.pack("C4") } }
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def draw_text(pixels, width, height, text, x, y, color, scale: 1)
|
|
387
|
+
text.to_s.upcase.each_char do |character|
|
|
388
|
+
glyph = FONT[character] || Array.new(7, "10001")
|
|
389
|
+
glyph.each_with_index do |row, row_index|
|
|
390
|
+
row.each_char.with_index do |value, column|
|
|
391
|
+
next unless value == "1"
|
|
392
|
+
|
|
393
|
+
rect(pixels, width, height, x + column * scale, y + row_index * scale, scale, scale, color)
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
x += 6 * scale
|
|
397
|
+
break if x >= width
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
data/lib/syrma/rspec.rb
CHANGED
|
@@ -102,6 +102,33 @@ module Syrma
|
|
|
102
102
|
failure_message { |session| "Expected menu items #{labels.inspect} (current: #{session.menu.items.inspect})" }
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
+
{
|
|
106
|
+
have_panel_visible: ->(id) { [UIAssertions.panel_id(id), UIAssertions::ANY_TEXT, "panel #{id.inspect} to be visible", "#{UIAssertions::PREFIX}:panel:"] },
|
|
107
|
+
have_panel_badge: ->(id, value) { [UIAssertions.panel_id(id, badge: true), value, "panel #{id.inspect} badge to be #{value.inspect}", "#{UIAssertions::PREFIX}:panel:"] },
|
|
108
|
+
have_inline_overlay: ->(line:, text:) { [UIAssertions.decoration_id(:inline, line), text, "inline overlay on line #{line} with text #{text.inspect}", "#{UIAssertions::PREFIX}:decoration:inline:"] },
|
|
109
|
+
have_gutter_marker: ->(line:, kind:) { [UIAssertions.decoration_id(:gutter, line, kind), UIAssertions::ANY_TEXT, "#{kind.inspect} gutter marker on line #{line}", "#{UIAssertions::PREFIX}:decoration:gutter:"] },
|
|
110
|
+
have_line_highlight: ->(line:, kind:) { [UIAssertions.decoration_id(:line, line, kind), UIAssertions::ANY_TEXT, "#{kind.inspect} line highlight on line #{line}", "#{UIAssertions::PREFIX}:decoration:line:"] }
|
|
111
|
+
}.each do |name, query|
|
|
112
|
+
::RSpec::Matchers.define name do |*args, **keywords|
|
|
113
|
+
match do |session|
|
|
114
|
+
@test_id, @text, @expectation, @prefix = query.call(*args, **keywords)
|
|
115
|
+
@session = session
|
|
116
|
+
RSpec.eventually(session) { UIAssertions.visible?(session, @test_id, text: @text) }
|
|
117
|
+
end
|
|
118
|
+
match_when_negated do |session|
|
|
119
|
+
@test_id, @text, @expectation, @prefix = query.call(*args, **keywords)
|
|
120
|
+
@session = session
|
|
121
|
+
RSpec.eventually(session) { !UIAssertions.visible?(session, @test_id, text: @text) }
|
|
122
|
+
end
|
|
123
|
+
failure_message do
|
|
124
|
+
"Expected #{@expectation}; observed #{UIAssertions.describe(@session, @prefix)}"
|
|
125
|
+
end
|
|
126
|
+
failure_message_when_negated do
|
|
127
|
+
"Expected #{@expectation} not to match; observed #{UIAssertions.describe(@session, @prefix)}"
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
105
132
|
::RSpec::Matchers.define :match_screenshot do |name, **options|
|
|
106
133
|
match do |session|
|
|
107
134
|
store = Snapshots::Store.new
|
data/lib/syrma/session.rb
CHANGED
|
@@ -68,6 +68,26 @@ module Syrma
|
|
|
68
68
|
|
|
69
69
|
def driver = @drivers[@current]
|
|
70
70
|
def session = self
|
|
71
|
+
def virtual_clock? = @clock.is_a?(Clock)
|
|
72
|
+
def record(fps: 12, scale: 1.0, max_frames: 600, seed: nil, chrome: {}, &block)
|
|
73
|
+
raise Error, "record requires a virtual clock" unless virtual_clock?
|
|
74
|
+
raise Error, "record requires a headless backend" unless @backend == :headless
|
|
75
|
+
raise ArgumentError, "record requires a block" unless block
|
|
76
|
+
|
|
77
|
+
recording = Recording.new(self, fps: fps, scale: scale, max_frames: max_frames, seed: seed, chrome: chrome)
|
|
78
|
+
block.call(recording)
|
|
79
|
+
recording.result
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def record_cast(fps: 12, seed: nil, max_frames: 600, &block)
|
|
83
|
+
raise UnsupportedBackend, "record_cast is only available for TUI sessions" unless tui?
|
|
84
|
+
raise Error, "record_cast requires a virtual clock" unless virtual_clock?
|
|
85
|
+
raise ArgumentError, "record_cast requires a block" unless block
|
|
86
|
+
|
|
87
|
+
recording = Recording.new(self, fps: fps, scale: 1.0, max_frames: max_frames, seed: seed, cast_only: true)
|
|
88
|
+
block.call(recording)
|
|
89
|
+
recording.result
|
|
90
|
+
end
|
|
71
91
|
def method_missing(name, ...) = driver.respond_to?(name) ? driver.public_send(name, ...) : super
|
|
72
92
|
def respond_to_missing?(name, include_all = false) = driver.respond_to?(name, include_all) || super
|
|
73
93
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Syrma
|
|
4
|
+
module UIAssertions
|
|
5
|
+
PREFIX = "syrma"
|
|
6
|
+
ANY_TEXT = Object.new.freeze
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def panel_id(id, badge: false) = [PREFIX, "panel", component(id, "panel id"), badge ? "badge" : nil].compact.join(":")
|
|
11
|
+
|
|
12
|
+
def decoration_id(kind, line, type = nil)
|
|
13
|
+
raise ArgumentError, "line must be a nonnegative integer" unless line.is_a?(Integer) && line >= 0
|
|
14
|
+
|
|
15
|
+
kind = component(kind, "decoration kind")
|
|
16
|
+
type = component(type, "decoration type") unless type.nil?
|
|
17
|
+
[PREFIX, "decoration", kind, line, type].compact.join(":")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def visible?(session, test_id, text: ANY_TEXT)
|
|
21
|
+
nodes(session, test_id).any? do |node|
|
|
22
|
+
node.visible? && (text.equal?(ANY_TEXT) || text_match?(node.content_text, text))
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def describe(session, prefix)
|
|
27
|
+
found = session.tree.nodes.select { |node| node.test_id&.start_with?(prefix) }
|
|
28
|
+
return "none" if found.empty?
|
|
29
|
+
|
|
30
|
+
found.first(10).map do |node|
|
|
31
|
+
"#{node.test_id}=#{node.content_text.inspect} (#{node.visible? ? 'visible' : 'hidden'})"
|
|
32
|
+
end.join(", ")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def nodes(session, test_id) = session.tree.nodes.select { |node| node.test_id == test_id }
|
|
36
|
+
|
|
37
|
+
def text_match?(actual, expected)
|
|
38
|
+
expected.is_a?(Regexp) ? expected.match?(actual) : actual == expected.to_s
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def component(value, label)
|
|
42
|
+
valid = value.is_a?(String) || value.is_a?(Symbol)
|
|
43
|
+
value = value.to_s
|
|
44
|
+
valid &&= !value.empty? && !value.include?(":") && !value.match?(/[\x00-\x1f\x7f]/)
|
|
45
|
+
raise ArgumentError, "#{label} must be a nonempty string or symbol without colons or control characters" unless valid
|
|
46
|
+
|
|
47
|
+
value
|
|
48
|
+
end
|
|
49
|
+
private_class_method :component
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/syrma/version.rb
CHANGED
data/lib/syrma/window_driver.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Syrma
|
|
|
9
9
|
def initialize(window, session)
|
|
10
10
|
@window = window
|
|
11
11
|
@session = session
|
|
12
|
-
Instrumentation.capture(window, lazy_raster: session.raster == :lazy)
|
|
12
|
+
Instrumentation.capture(window, lazy_raster: session.raster == :lazy, reduce_motion: session.virtual_clock?)
|
|
13
13
|
@builder = SnapshotBuilder.new
|
|
14
14
|
end
|
|
15
15
|
|
|
@@ -162,9 +162,19 @@ module Syrma
|
|
|
162
162
|
def scroll_until(list, found:, step: 100, max: 50)
|
|
163
163
|
target = find(**found)
|
|
164
164
|
max.times do
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
165
|
+
candidates = target.resolve_all
|
|
166
|
+
return target if candidates.any?(&:visible?)
|
|
167
|
+
|
|
168
|
+
container = resolve_node(list)
|
|
169
|
+
candidate = candidates.first
|
|
170
|
+
dy = if candidate && candidate.bounds.y >= container.visible_bounds.y + container.visible_bounds.height
|
|
171
|
+
candidate.bounds.y - container.visible_bounds.y - container.visible_bounds.height + 1
|
|
172
|
+
elsif candidate && candidate.bounds.y + candidate.bounds.height <= container.visible_bounds.y
|
|
173
|
+
candidate.bounds.y + candidate.bounds.height - container.visible_bounds.y - 1
|
|
174
|
+
else
|
|
175
|
+
step
|
|
176
|
+
end
|
|
177
|
+
scroll(list, dy: dy)
|
|
168
178
|
end
|
|
169
179
|
raise ElementNotFound.new(target, tree)
|
|
170
180
|
end
|
data/lib/syrma.rb
CHANGED
|
@@ -10,10 +10,12 @@ require_relative "syrma/clock"
|
|
|
10
10
|
require_relative "syrma/text_systems"
|
|
11
11
|
require_relative "syrma/snapshot"
|
|
12
12
|
require_relative "syrma/query"
|
|
13
|
+
require_relative "syrma/ui_assertions"
|
|
13
14
|
require_relative "syrma/event_log"
|
|
14
15
|
require_relative "syrma/event_codec"
|
|
15
16
|
require_relative "syrma/recorder"
|
|
16
17
|
require_relative "syrma/window_driver"
|
|
18
|
+
require_relative "syrma/recording"
|
|
17
19
|
require_relative "syrma/popup_driver"
|
|
18
20
|
require_relative "syrma/session"
|
|
19
21
|
require_relative "syrma/visual/comparator"
|
data/sig/syrma.rbs
CHANGED
|
@@ -145,6 +145,9 @@ module Syrma
|
|
|
145
145
|
def window: (?title: String?, ?index: Integer?) -> untyped
|
|
146
146
|
def driver: () -> WindowDriver
|
|
147
147
|
def session: () -> Session
|
|
148
|
+
def virtual_clock?: () -> bool
|
|
149
|
+
def record: (?fps: Numeric, ?scale: Numeric, ?max_frames: Integer, ?seed: Integer?, ?chrome: Hash[Symbol, untyped]) { (Recording) -> untyped } -> Recording::Result
|
|
150
|
+
def record_cast: (?fps: Numeric, ?seed: Integer?, ?max_frames: Integer) { (Recording) -> untyped } -> Recording::Result
|
|
148
151
|
def settle: () -> Session
|
|
149
152
|
def advance: (Numeric seconds) -> Session
|
|
150
153
|
def wait_for: (?untyped message, ?timeout: Numeric) { () -> untyped } -> untyped
|
|
@@ -156,6 +159,36 @@ module Syrma
|
|
|
156
159
|
def method_missing: (Symbol name, *untyped, **untyped) -> untyped
|
|
157
160
|
end
|
|
158
161
|
|
|
162
|
+
class Recording
|
|
163
|
+
class Frame
|
|
164
|
+
attr_reader rgba: String
|
|
165
|
+
attr_reader delay_ms: Integer
|
|
166
|
+
end
|
|
167
|
+
class Result
|
|
168
|
+
attr_reader animation: untyped
|
|
169
|
+
attr_reader cast: untyped
|
|
170
|
+
attr_reader frames: Array[Frame]
|
|
171
|
+
attr_reader duration_ms: Integer
|
|
172
|
+
def write_apng: (String path, **untyped) -> String
|
|
173
|
+
def write_gif: (String path, **untyped) -> String
|
|
174
|
+
def write_png: (String path) -> String
|
|
175
|
+
def write_cast: (String path, **untyped) -> String
|
|
176
|
+
end
|
|
177
|
+
def advance: (Numeric seconds) -> self
|
|
178
|
+
def pause: (Numeric seconds) -> self
|
|
179
|
+
def frame: (?count: Integer) -> self
|
|
180
|
+
def type_humanly: (String text, ?cps: Numeric, ?jitter: Numeric) -> self
|
|
181
|
+
def press_slowly: (*String keystrokes, ?hold: Numeric, ?gap: Numeric) -> self
|
|
182
|
+
def click_slowly: (untyped target, ?approach: Numeric, **untyped) -> self
|
|
183
|
+
def drag_slowly: (untyped from, untyped to, ?duration: Numeric) -> self
|
|
184
|
+
def scroll_smoothly: (untyped target, dy: Numeric, ?duration: Numeric) -> self
|
|
185
|
+
def cursor: (?visible: bool, ?style: Symbol) -> self
|
|
186
|
+
def keycaps: (?visible: bool, ?duration: Numeric) -> self
|
|
187
|
+
def highlight: (untyped target, ?padding: Numeric, ?duration: Numeric) -> self
|
|
188
|
+
def caption: (String text, ?duration: Numeric, ?position: Symbol) -> self
|
|
189
|
+
def zoom: (untyped target, ?scale: Numeric, ?duration: Numeric) -> self
|
|
190
|
+
end
|
|
191
|
+
|
|
159
192
|
class WindowDriver
|
|
160
193
|
attr_reader window: untyped
|
|
161
194
|
attr_reader session: Session
|
|
@@ -258,6 +291,11 @@ module Syrma
|
|
|
258
291
|
def assert_pixel: (Integer x, Integer y, untyped color, **untyped) -> bool
|
|
259
292
|
def assert_tooltip: (String | Regexp expected, **untyped) -> bool
|
|
260
293
|
def assert_menu_items: (Array[String] labels, **untyped) -> bool
|
|
294
|
+
def assert_panel_visible: (String | Symbol id, **untyped) -> bool
|
|
295
|
+
def assert_panel_badge: (String | Symbol id, untyped value, **untyped) -> bool
|
|
296
|
+
def assert_inline_overlay: (line: Integer, text: String | Regexp, **untyped) -> bool
|
|
297
|
+
def assert_gutter_marker: (line: Integer, kind: String | Symbol, **untyped) -> bool
|
|
298
|
+
def assert_line_highlight: (line: Integer, kind: String | Symbol, **untyped) -> bool
|
|
261
299
|
def assert_screenshot: (String name, **untyped) -> bool
|
|
262
300
|
def assert_tree_snapshot: (String name, **untyped) -> bool
|
|
263
301
|
def assert_terminal_snapshot: (String name, **untyped) -> bool
|
metadata
CHANGED
|
@@ -1,28 +1,55 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: syrma
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: exe
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
+
date: 2026-09-20 00:00:00.000000000 Z
|
|
11
12
|
dependencies:
|
|
12
13
|
- !ruby/object:Gem::Dependency
|
|
13
14
|
name: zaniah
|
|
14
15
|
requirement: !ruby/object:Gem::Requirement
|
|
15
16
|
requirements:
|
|
16
|
-
- - "
|
|
17
|
+
- - ">="
|
|
17
18
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: 0.2
|
|
19
|
+
version: '0.2'
|
|
20
|
+
- - "<"
|
|
21
|
+
- !ruby/object:Gem::Version
|
|
22
|
+
version: '0.6'
|
|
19
23
|
type: :runtime
|
|
20
24
|
prerelease: false
|
|
21
25
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
26
|
requirements:
|
|
23
|
-
- - "
|
|
27
|
+
- - ">="
|
|
28
|
+
- !ruby/object:Gem::Version
|
|
29
|
+
version: '0.2'
|
|
30
|
+
- - "<"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.6'
|
|
33
|
+
- !ruby/object:Gem::Dependency
|
|
34
|
+
name: wezen
|
|
35
|
+
requirement: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.1'
|
|
40
|
+
- - "<"
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '0.2'
|
|
43
|
+
type: :runtime
|
|
44
|
+
prerelease: false
|
|
45
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '0.1'
|
|
50
|
+
- - "<"
|
|
24
51
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: 0.2
|
|
52
|
+
version: '0.2'
|
|
26
53
|
description: 'Drive Zaniah windows headlessly: locate elements, send real input events,
|
|
27
54
|
and assert text, layout, pixels, and snapshots.'
|
|
28
55
|
email:
|
|
@@ -52,6 +79,7 @@ files:
|
|
|
52
79
|
- lib/syrma/query.rb
|
|
53
80
|
- lib/syrma/rake_task.rb
|
|
54
81
|
- lib/syrma/recorder.rb
|
|
82
|
+
- lib/syrma/recording.rb
|
|
55
83
|
- lib/syrma/report.rb
|
|
56
84
|
- lib/syrma/rspec.rb
|
|
57
85
|
- lib/syrma/script_launcher.rb
|
|
@@ -61,18 +89,20 @@ files:
|
|
|
61
89
|
- lib/syrma/snapshots/terminal_format.rb
|
|
62
90
|
- lib/syrma/snapshots/tree_format.rb
|
|
63
91
|
- lib/syrma/text_systems.rb
|
|
92
|
+
- lib/syrma/ui_assertions.rb
|
|
64
93
|
- lib/syrma/version.rb
|
|
65
94
|
- lib/syrma/visual/comparator.rb
|
|
66
95
|
- lib/syrma/visual/image.rb
|
|
67
96
|
- lib/syrma/window_driver.rb
|
|
68
97
|
- sig/syrma.rbs
|
|
69
|
-
homepage: https://github.com/
|
|
98
|
+
homepage: https://github.com/noxdea/syrma
|
|
70
99
|
licenses:
|
|
71
100
|
- MIT
|
|
72
101
|
metadata:
|
|
73
|
-
source_code_uri: https://github.com/
|
|
74
|
-
changelog_uri: https://github.com/
|
|
102
|
+
source_code_uri: https://github.com/noxdea/syrma
|
|
103
|
+
changelog_uri: https://github.com/noxdea/syrma/blob/main/CHANGELOG.md
|
|
75
104
|
rubygems_mfa_required: 'true'
|
|
105
|
+
post_install_message:
|
|
76
106
|
rdoc_options: []
|
|
77
107
|
require_paths:
|
|
78
108
|
- lib
|
|
@@ -87,7 +117,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
87
117
|
- !ruby/object:Gem::Version
|
|
88
118
|
version: '0'
|
|
89
119
|
requirements: []
|
|
90
|
-
rubygems_version: 4.
|
|
120
|
+
rubygems_version: 3.4.19
|
|
121
|
+
signing_key:
|
|
91
122
|
specification_version: 4
|
|
92
123
|
summary: UI testing toolkit for applications built with Zaniah
|
|
93
124
|
test_files: []
|