syrma 0.2.0 → 0.4.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: 0125f382e153f0c3485107cf3cbe4719733da7b250f3e2e7aeac507c7f3beab7
4
- data.tar.gz: 53f0fd7a4f089b5a8b42e3ea36a2e0edef6c9aec39c6b1c8d006cd1850dcfa8a
3
+ metadata.gz: be544217dc4ba67f34fa4cb6a824be44ab74dee130324f615e0824c5ab23fd72
4
+ data.tar.gz: 970b020a33399800791693b1922aacdfa679d4260947c75ff938838b31ad8794
5
5
  SHA512:
6
- metadata.gz: 2a124116d448b9c7d85cacb595e57a155b11cbf1f1f4a7ec2b97904d9b99aff2f00c35751c435fd2563a9efaa31ab8a53c098f192954c270fe95ad719d789a66
7
- data.tar.gz: 299ed17ddf94e4c81db7f80517e3402640a55af4768386e5e0ac40668f40d015127cc738e79f0e1a9bb5da639ad8a32ce9b59e191a413b5dc700ae55ce770772
6
+ metadata.gz: a2e0b783bc7e6c66fba35676fc1d10117e4c0436362eb494f32d0d5e857ff2a1ad50f2f743722fd13ae31633a6a5ba9fe9a0475d79cbf1796edf3f94a9a5f878
7
+ data.tar.gz: f9d84eba21b734e63dc793461b7f3b45fb75be5ffc862915ede839c245e269296b4dd8690c570a3cd29f9236aa02263d31dfc4b06ce278e5934f01e6c309ab9b
data/CHANGELOG.md CHANGED
@@ -1,6 +1,13 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.4.0 - 2026-09-24
4
+
5
+ - Support Zaniah 0.7 inspection snapshots and semantic accessibility queries.
6
+ - Use Zaniah's public font, executor, and frame APIs instead of internal instrumentation.
7
+
8
+ ## 0.3.0 - 2026-09-20
9
+
10
+ - Add deterministic virtual-clock demo recording with humanized input, pixel-space chrome, and Wezen output hooks.
4
11
 
5
12
  ## 0.2.0 - 2026-09-15
6
13
 
data/README.md CHANGED
@@ -24,6 +24,8 @@
24
24
 
25
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
26
 
27
+ ![Syrma deterministic recording](docs/media/overview.png)
28
+
27
29
  ## Features
28
30
 
29
31
  - Drive GUI and TUI sessions with pointer, keyboard, clipboard, composition, file-drop, resize, and terminal input
@@ -32,6 +34,7 @@ Syrma locates rendered elements, sends input through Zaniah's real event path, w
32
34
  - Keep timing deterministic with a session-scoped virtual clock and explicit frame settling
33
35
  - Test with Minitest assertions or RSpec matchers
34
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
35
38
 
36
39
  ## Installation
37
40
 
@@ -51,7 +54,24 @@ bundle install
51
54
  bundle exec syrma doctor
52
55
  ```
53
56
 
54
- 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.
57
+ Syrma requires Ruby 3.1 or later and supports Zaniah `>= 0.7, < 1.0`. 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.
55
75
 
56
76
  ## Quick start
57
77
 
@@ -115,11 +135,13 @@ end
115
135
  | Keyboard/text | `press`, `type`, `paste`, `compose`, `commit` |
116
136
  | Window/input | `resize`, `close`, `drop_files`, `feed_terminal` |
117
137
  | Synchronize | `settle`, `wait_for`, `advance` |
118
- | Inspect | `tree`, `texts`, `at`, `pixel`, `screenshot`, `terminal_lines`, `menu`, `tooltip` |
138
+ | Inspect | `tree`, `texts`, `at`, `accessibility`, `pixel`, `screenshot`, `terminal_lines`, `menu`, `tooltip` |
119
139
  | Assert | Text, element, visibility, panels, decorations, background, pixel, tooltip, menu, tree/terminal/image snapshots |
120
140
 
121
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`.
122
142
 
143
+ `ui.accessibility(role: :button, label: "Save")` searches Zaniah's semantic tree and returns `[node, path]` pairs. Unlike visual locators, semantic nodes need not correspond to a rendered element or have bounds; use `ui.find` for pointer actions.
144
+
123
145
  Panel and decoration assertions use rendered `test_id` instrumentation, so application code does not need a Syrma or Canopus runtime dependency:
124
146
 
125
147
  ```ruby
@@ -165,6 +187,7 @@ On Ruby 4.0 arm64 macOS, the included 101-element benchmark measured `event_fram
165
187
  ```sh
166
188
  bundle install
167
189
  bundle exec rake
190
+ bundle exec rake demo
168
191
  bundle exec ruby -Ilib:test script/test_gesture.rb
169
192
  bundle exec rbs -I sig validate
170
193
  gem build --strict syrma.gemspec
data/lib/syrma/cli.rb CHANGED
@@ -40,7 +40,7 @@ module Syrma
40
40
  checks = {
41
41
  "Ruby" => "#{RUBY_VERSION} (#{RUBY_PLATFORM})",
42
42
  "zaniah" => Zaniah::VERSION,
43
- "Bundled font" => File.file?(Internals.bundled_font_path) ? Internals.bundled_font_path : nil,
43
+ "Bundled font" => File.file?(Zaniah.bundled_font_path) ? Zaniah.bundled_font_path : nil,
44
44
  "YJIT" => defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? ? "enabled" : "disabled",
45
45
  "External encoding" => Encoding.default_external.name
46
46
  }
@@ -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/session.rb CHANGED
@@ -50,7 +50,7 @@ module Syrma
50
50
  @outputs = {}
51
51
  @windows = attached_windows || [open_window(backend, width, height, window_options, keymap)]
52
52
  @drivers = @windows.map { |window| prepare(window, backend, text, fonts) }
53
- @font_paths = text == :deterministic ? [Internals.bundled_font_path, *fonts.map { |font| File.expand_path(font) }] : []
53
+ @font_paths = text == :deterministic ? [Zaniah.bundled_font_path, *fonts.map { |font| File.expand_path(font) }] : []
54
54
  @current = 0
55
55
  mount&.call(window)
56
56
  settle
@@ -69,6 +69,25 @@ module Syrma
69
69
  def driver = @drivers[@current]
70
70
  def session = self
71
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
72
91
  def method_missing(name, ...) = driver.respond_to?(name) ? driver.public_send(name, ...) : super
73
92
  def respond_to_missing?(name, include_all = false) = driver.respond_to?(name, include_all) || super
74
93
 
@@ -83,7 +102,7 @@ module Syrma
83
102
  candidate.tick
84
103
  busy = true
85
104
  end
86
- return self unless busy || (@app && !Internals.executor_idle?(@app.executor))
105
+ return self unless busy || (@app && !@app.executor.idle?)
87
106
  end
88
107
  raise UnstableUI, "UI did not stabilize within #{@max_frames} frames (continuous animation?)"
89
108
  end
@@ -14,10 +14,11 @@ module Syrma
14
14
  def inspect = "#<#{type}#{test_id && " @#{test_id}"}#{text && " #{text.inspect}"} #{path.join('.')}>"
15
15
  end
16
16
 
17
- Tree = Data.define(:frame, :size, :root, :nodes, :hits, :text_runs, :menu, :menu_index, :tooltip) do
17
+ Tree = Data.define(:frame, :size, :root, :nodes, :hits, :text_runs, :menu, :menu_index, :tooltip, :hit_regions) do
18
18
  def hit_at(point, event: :mouse_down, button: :left)
19
- hits.reverse_each do |bounds, node|
20
- next unless bounds.contains?(point)
19
+ hits.length.times.reverse_each do |index|
20
+ _bounds, node = hits[index]
21
+ next unless hit_regions[index].contains?(point)
21
22
 
22
23
  return node if node.nil? || Tree.handles?(node, event, button)
23
24
  end
@@ -27,7 +28,7 @@ module Syrma
27
28
  def self.handles?(node, event, button)
28
29
  case event
29
30
  when :mouse_down
30
- (button == :right && Internals.context_menu(node.element)) ||
31
+ (button == :right && node.element.respond_to?(:context_menu_items) && node.element.context_menu_items) ||
31
32
  (node.handlers & %i[mouse_down click drag]).any?
32
33
  when :mouse_move then node.handlers.include?(:hover) || node.tooltip
33
34
  when :mouse_up then node.handlers.include?(:mouse_up)
@@ -38,40 +39,43 @@ module Syrma
38
39
 
39
40
  class SnapshotBuilder
40
41
  def build(window)
42
+ snapshot = Zaniah::Inspection.snapshot(window)
41
43
  by_element = {}.compare_by_identity
42
44
  nodes = []
43
45
  viewport = Zaniah::Bounds.new(0, 0, window.content_size.width, window.content_size.height)
44
- root = window.testing_root && visit(window.testing_root, viewport, [0], nodes, by_element)
45
- hits = window.dispatcher.hits.map { |hit| [hit.bounds, hit.owner && by_element[hit.owner]] }
46
- popup = window.popup
47
- Tree.new(frame: window.testing_frame, size: window.content_size, root: root,
48
- nodes: nodes.freeze, hits: hits.freeze, text_runs: window.text_runs.map(&:dup).freeze,
46
+ root = snapshot.root && visit(snapshot.root, viewport, [0], nodes, by_element)
47
+ hits = snapshot.hits.map { |hit| [hit.bounds, hit.owner && by_element[hit.owner]] }
48
+ popup = snapshot.overlays.popup
49
+ tooltip = snapshot.overlays.tooltip
50
+ Tree.new(frame: snapshot.frame.number, size: window.content_size, root: root,
51
+ nodes: nodes.freeze, hits: hits.freeze, hit_regions: snapshot.hits, text_runs: snapshot.text_runs,
49
52
  menu: popup&.labels, menu_index: popup&.selected_index,
50
- tooltip: Internals.shown_tooltip(window))
53
+ tooltip: tooltip && tooltip[:shown] ? tooltip[:text] : nil)
51
54
  end
52
55
 
53
56
  private
54
57
 
55
- def visit(element, clip, path, nodes, by_element)
56
- bounds = element.layout_node&.bounds or return nil
57
- style = Internals.style(element)
58
+ def visit(entry, clip, path, nodes, by_element)
59
+ bounds = entry.bounds or return nil
60
+ style = entry.style
58
61
  return nil if style[:display] == :none
59
62
 
60
63
  visible = bounds.intersect(clip)
61
64
  child_clip = style[:overflow] == :visible ? clip : visible
62
65
  index = nodes.length
63
66
  nodes << nil
64
- children = element.children.each_with_index.filter_map do |child, child_index|
67
+ children = entry.children.each_with_index.filter_map do |child, child_index|
65
68
  visit(child, child_clip, path + [child_index], nodes, by_element)
66
69
  end
70
+ element = entry.element
67
71
  node = Node.new(
68
- path: path.freeze, element: element, type: element.class.name.split("::").last.downcase.to_sym,
69
- test_id: element.test_id, key: Internals.key(element),
72
+ path: path.freeze, element: element, type: entry.type.split("::").last.downcase.to_sym,
73
+ test_id: entry.test_id, key: entry.key,
70
74
  text: text_attr(element, :text), color: text_attr(element, :text_color),
71
75
  font_size: text_attr(element, :font_size), bounds: bounds, visible_bounds: visible,
72
- background: Internals.background(element), border_color: Internals.border_color(element),
73
- radius: Internals.radius(element), handlers: element.handlers,
74
- tooltip: Internals.tooltip(element), children: children.freeze
76
+ background: style[:background], border_color: style[:border_color],
77
+ radius: style[:corner_radii], handlers: entry.handlers,
78
+ tooltip: entry.tooltip, children: children.freeze
75
79
  )
76
80
  nodes[index] = node
77
81
  by_element[element] = node
@@ -9,7 +9,7 @@ module Syrma
9
9
  when :none, nil then nil
10
10
  when :native then Zaniah::TextSystem::Renderer.new
11
11
  when :deterministic
12
- paths = [Internals.bundled_font_path, *fonts.map { |font| File.expand_path(font) }]
12
+ paths = [Zaniah.bundled_font_path, *fonts.map { |font| File.expand_path(font) }]
13
13
  missing = paths.reject { |path| File.file?(path) }
14
14
  raise ArgumentError, "Fonts not found: #{missing.join(', ')}" unless missing.empty?
15
15
 
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.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -17,7 +17,7 @@ module Syrma
17
17
 
18
18
  def tree
19
19
  session.settle
20
- @tree = nil if @tree && @tree.frame != window.testing_frame
20
+ @tree = nil if @tree && @tree.frame != window.frame_number
21
21
  @tree ||= @builder.build(window)
22
22
  end
23
23
 
@@ -27,6 +27,10 @@ module Syrma
27
27
  def text(value) = find(text: value)
28
28
  def texts = tree.text_runs.map { |run| run[2] }
29
29
  def tooltip = tree.tooltip
30
+ def accessibility(role: nil, label: nil, states: {})
31
+ session.settle
32
+ Zaniah::Inspection.snapshot(window).accessibility.query(role: role, label: label, states: states)
33
+ end
30
34
 
31
35
  def button(label)
32
36
  find(clickable: true) do |node|
@@ -182,7 +186,7 @@ module Syrma
182
186
  def menu = (@menu_driver ||= PopupDriver.new(self))
183
187
 
184
188
  def dispatch(event)
185
- session.event_log.add(window.testing_frame, window.title, event)
189
+ session.event_log.add(window.frame_number, window.title, event)
186
190
  window.input(event)
187
191
  session.settle unless @in_gesture && session.event_frames == :gesture
188
192
  event
data/lib/syrma.rb CHANGED
@@ -4,7 +4,6 @@ require "zaniah"
4
4
  require_relative "syrma/version"
5
5
  require_relative "syrma/configuration"
6
6
  require_relative "syrma/errors"
7
- require_relative "syrma/internals"
8
7
  require_relative "syrma/instrumentation"
9
8
  require_relative "syrma/clock"
10
9
  require_relative "syrma/text_systems"
@@ -15,6 +14,7 @@ require_relative "syrma/event_log"
15
14
  require_relative "syrma/event_codec"
16
15
  require_relative "syrma/recorder"
17
16
  require_relative "syrma/window_driver"
17
+ require_relative "syrma/recording"
18
18
  require_relative "syrma/popup_driver"
19
19
  require_relative "syrma/session"
20
20
  require_relative "syrma/visual/comparator"
data/sig/syrma.rbs CHANGED
@@ -78,6 +78,7 @@ module Syrma
78
78
  attr_reader root: Node?
79
79
  attr_reader nodes: Array[Node]
80
80
  attr_reader hits: Array[untyped]
81
+ attr_reader hit_regions: Array[untyped]
81
82
  attr_reader text_runs: Array[untyped]
82
83
  attr_reader menu: Array[String]?
83
84
  attr_reader menu_index: Integer?
@@ -146,6 +147,8 @@ module Syrma
146
147
  def driver: () -> WindowDriver
147
148
  def session: () -> Session
148
149
  def virtual_clock?: () -> bool
150
+ def record: (?fps: Numeric, ?scale: Numeric, ?max_frames: Integer, ?seed: Integer?, ?chrome: Hash[Symbol, untyped]) { (Recording) -> untyped } -> Recording::Result
151
+ def record_cast: (?fps: Numeric, ?seed: Integer?, ?max_frames: Integer) { (Recording) -> untyped } -> Recording::Result
149
152
  def settle: () -> Session
150
153
  def advance: (Numeric seconds) -> Session
151
154
  def wait_for: (?untyped message, ?timeout: Numeric) { () -> untyped } -> untyped
@@ -157,6 +160,36 @@ module Syrma
157
160
  def method_missing: (Symbol name, *untyped, **untyped) -> untyped
158
161
  end
159
162
 
163
+ class Recording
164
+ class Frame
165
+ attr_reader rgba: String
166
+ attr_reader delay_ms: Integer
167
+ end
168
+ class Result
169
+ attr_reader animation: untyped
170
+ attr_reader cast: untyped
171
+ attr_reader frames: Array[Frame]
172
+ attr_reader duration_ms: Integer
173
+ def write_apng: (String path, **untyped) -> String
174
+ def write_gif: (String path, **untyped) -> String
175
+ def write_png: (String path) -> String
176
+ def write_cast: (String path, **untyped) -> String
177
+ end
178
+ def advance: (Numeric seconds) -> self
179
+ def pause: (Numeric seconds) -> self
180
+ def frame: (?count: Integer) -> self
181
+ def type_humanly: (String text, ?cps: Numeric, ?jitter: Numeric) -> self
182
+ def press_slowly: (*String keystrokes, ?hold: Numeric, ?gap: Numeric) -> self
183
+ def click_slowly: (untyped target, ?approach: Numeric, **untyped) -> self
184
+ def drag_slowly: (untyped from, untyped to, ?duration: Numeric) -> self
185
+ def scroll_smoothly: (untyped target, dy: Numeric, ?duration: Numeric) -> self
186
+ def cursor: (?visible: bool, ?style: Symbol) -> self
187
+ def keycaps: (?visible: bool, ?duration: Numeric) -> self
188
+ def highlight: (untyped target, ?padding: Numeric, ?duration: Numeric) -> self
189
+ def caption: (String text, ?duration: Numeric, ?position: Symbol) -> self
190
+ def zoom: (untyped target, ?scale: Numeric, ?duration: Numeric) -> self
191
+ end
192
+
160
193
  class WindowDriver
161
194
  attr_reader window: untyped
162
195
  attr_reader session: Session
@@ -168,6 +201,7 @@ module Syrma
168
201
  def text: (String | Regexp value) -> Locator
169
202
  def texts: () -> Array[String]
170
203
  def tooltip: () -> String?
204
+ def accessibility: (?role: Symbol?, ?label: String | Regexp | nil, ?states: Hash[Symbol, untyped]) -> Array[[untyped, Array[Integer]]]
171
205
  def button: (String label) -> Locator
172
206
  def at: (untyped x, ?untyped y, ?event: Symbol, ?button: Symbol) -> Node?
173
207
  def pixel: (Integer x, Integer y) -> Array[Integer]
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: syrma
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yudai Takada
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-15 00:00:00.000000000 Z
11
+ date: 2026-09-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: zaniah
@@ -16,20 +16,40 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '0.2'
19
+ version: '0.7'
20
20
  - - "<"
21
21
  - !ruby/object:Gem::Version
22
- version: '0.6'
22
+ version: '1.0'
23
23
  type: :runtime
24
24
  prerelease: false
25
25
  version_requirements: !ruby/object:Gem::Requirement
26
26
  requirements:
27
27
  - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '0.7'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.0'
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
+ - - "<"
28
41
  - !ruby/object:Gem::Version
29
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'
30
50
  - - "<"
31
51
  - !ruby/object:Gem::Version
32
- version: '0.6'
52
+ version: '0.2'
33
53
  description: 'Drive Zaniah windows headlessly: locate elements, send real input events,
34
54
  and assert text, layout, pixels, and snapshots.'
35
55
  email:
@@ -53,12 +73,12 @@ files:
53
73
  - lib/syrma/event_codec.rb
54
74
  - lib/syrma/event_log.rb
55
75
  - lib/syrma/instrumentation.rb
56
- - lib/syrma/internals.rb
57
76
  - lib/syrma/minitest.rb
58
77
  - lib/syrma/popup_driver.rb
59
78
  - lib/syrma/query.rb
60
79
  - lib/syrma/rake_task.rb
61
80
  - lib/syrma/recorder.rb
81
+ - lib/syrma/recording.rb
62
82
  - lib/syrma/report.rb
63
83
  - lib/syrma/rspec.rb
64
84
  - lib/syrma/script_launcher.rb
@@ -1,32 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Syrma
4
- module Internals
5
- module_function
6
-
7
- def style(element) = element.instance_variable_get(:@style) || {}
8
- def background(element) = 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
- def tooltip(element) = element.instance_variable_get(:@tooltip)
12
- def context_menu(element) = element.instance_variable_get(:@context_menu)
13
- def key(element) = element.instance_variable_get(:@key)
14
- def shown_tooltip(window)
15
- tip = window.instance_variable_get(:@tooltip)
16
- tip && tip[:shown] ? tip[:text] : nil
17
- end
18
-
19
- def bundled_font_path
20
- _, entry = $LOAD_PATH.resolve_feature_path("zaniah")
21
- File.expand_path("../assets/fonts/Abel-Regular.ttf", File.dirname(entry))
22
- end
23
-
24
- def executor_idle?(executor) = executor.instance_variable_get(:@foreground).empty?
25
-
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
31
- end
32
- end