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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e9ef547e116ba3d928630699b7ae95646b0604108499429bc978bf31faa502bf
4
- data.tar.gz: 01de9448ef8c33a18af96280ba15a993cf08be62292408de956aa8be78572a8c
3
+ metadata.gz: b76bd8cacebc9d29ee6b28a913efb6ef5ec7283cdf6462279064343207925a71
4
+ data.tar.gz: 5ee18a3987ecee83fd8c2a334afee51c1a161f3e892c155d0a9b1ecda3e0e68a
5
5
  SHA512:
6
- metadata.gz: 15af45c54231f18c6c10b7db2354a644bc206c584c515a169672ad562db43753f5753ececea2c829b04d418b83181c6689c0571be115dea91109425b74bc0342
7
- data.tar.gz: 15c60ea282171fb9e17427769fd81b5510218590afb0126d51349bf16a73c5b5a58b9657fe0a27e3d1140a3fcfc3e70270c013c02445dbedc71ce0cbb93b2640
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
- # Syrma
1
+ <h1 align="center">Syrma</h1>
2
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.
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
+ ![Syrma deterministic recording](docs/media/overview.png)
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 run `bundle install` and `bundle exec syrma doctor`.
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
- ## Minimal test
76
+ ## Quick start
19
77
 
20
- Keep window creation separate from the code that mounts your UI:
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.find(test_id: "increment").click
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 | text, element, visibility, clickability, background, pixel, tooltip, menu, tree/terminal/image snapshots |
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 speed
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
- See [the guide](docs/guide.md) and [recipes](docs/recipes.md) for the full workflow.
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
- Syrma supports Ruby 3.1+ and Zaniah `~> 0.2.0`. Virtual time is scoped to each session.
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
- super(element, present: present && !testing_lazy_raster, **options)
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
@@ -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.instance_variable_get(:@background)
9
- def border_color(element) = element.instance_variable_get(:@border_color)
10
- def radius(element) = element.instance_variable_get(:@radius)
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Syrma
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -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
- return target if target.exists? && target.resolve_all.any?(&:visible?)
166
-
167
- scroll(list, dy: step)
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.1.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: 1980-01-02 00:00:00.000000000 Z
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.0
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.0
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/ydah/syrma
98
+ homepage: https://github.com/noxdea/syrma
70
99
  licenses:
71
100
  - MIT
72
101
  metadata:
73
- source_code_uri: https://github.com/ydah/syrma
74
- changelog_uri: https://github.com/ydah/syrma/blob/main/CHANGELOG.md
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.0.19
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: []