syrma 0.2.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: 0125f382e153f0c3485107cf3cbe4719733da7b250f3e2e7aeac507c7f3beab7
4
- data.tar.gz: 53f0fd7a4f089b5a8b42e3ea36a2e0edef6c9aec39c6b1c8d006cd1850dcfa8a
3
+ metadata.gz: b76bd8cacebc9d29ee6b28a913efb6ef5ec7283cdf6462279064343207925a71
4
+ data.tar.gz: 5ee18a3987ecee83fd8c2a334afee51c1a161f3e892c155d0a9b1ecda3e0e68a
5
5
  SHA512:
6
- metadata.gz: 2a124116d448b9c7d85cacb595e57a155b11cbf1f1f4a7ec2b97904d9b99aff2f00c35751c435fd2563a9efaa31ab8a53c098f192954c270fe95ad719d789a66
7
- data.tar.gz: 299ed17ddf94e4c81db7f80517e3402640a55af4768386e5e0ac40668f40d015127cc738e79f0e1a9bb5da639ad8a32ce9b59e191a413b5dc700ae55ce770772
6
+ metadata.gz: 8166b829a204806d7c53c101bebab2b1568e95d2af3756510aa85eecf76a9234602b656a316388ce5f0c2f09b4e949b6ba18076aaabf8b2b9e2146ba69d88348
7
+ data.tar.gz: 11694f50f19407679166aa2cc305c1418832a3075166e76d9153cbd17b31996989f7211bd7b9997ba9f8818903a17147f5342196cd6d2ff4a4f7d9aa4ef6214f
data/CHANGELOG.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
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.
4
6
 
5
7
  ## 0.2.0 - 2026-09-15
6
8
 
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
 
@@ -53,6 +56,23 @@ bundle exec syrma doctor
53
56
 
54
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.
55
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.
75
+
56
76
  ## Quick start
57
77
 
58
78
  Keep window creation separate from the code that mounts the interface:
@@ -165,6 +185,7 @@ On Ruby 4.0 arm64 macOS, the included 101-element benchmark measured `event_fram
165
185
  ```sh
166
186
  bundle install
167
187
  bundle exec rake
188
+ bundle exec rake demo
168
189
  bundle exec ruby -Ilib:test script/test_gesture.rb
169
190
  bundle exec rbs -I sig validate
170
191
  gem build --strict syrma.gemspec
@@ -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
@@ -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
 
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.3.0"
5
5
  end
data/lib/syrma.rb CHANGED
@@ -15,6 +15,7 @@ require_relative "syrma/event_log"
15
15
  require_relative "syrma/event_codec"
16
16
  require_relative "syrma/recorder"
17
17
  require_relative "syrma/window_driver"
18
+ require_relative "syrma/recording"
18
19
  require_relative "syrma/popup_driver"
19
20
  require_relative "syrma/session"
20
21
  require_relative "syrma/visual/comparator"
data/sig/syrma.rbs CHANGED
@@ -146,6 +146,8 @@ module Syrma
146
146
  def driver: () -> WindowDriver
147
147
  def session: () -> Session
148
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
149
151
  def settle: () -> Session
150
152
  def advance: (Numeric seconds) -> Session
151
153
  def wait_for: (?untyped message, ?timeout: Numeric) { () -> untyped } -> untyped
@@ -157,6 +159,36 @@ module Syrma
157
159
  def method_missing: (Symbol name, *untyped, **untyped) -> untyped
158
160
  end
159
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
+
160
192
  class WindowDriver
161
193
  attr_reader window: untyped
162
194
  attr_reader session: Session
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.3.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-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: zaniah
@@ -30,6 +30,26 @@ dependencies:
30
30
  - - "<"
31
31
  - !ruby/object:Gem::Version
32
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
+ - - "<"
51
+ - !ruby/object:Gem::Version
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:
@@ -59,6 +79,7 @@ files:
59
79
  - lib/syrma/query.rb
60
80
  - lib/syrma/rake_task.rb
61
81
  - lib/syrma/recorder.rb
82
+ - lib/syrma/recording.rb
62
83
  - lib/syrma/report.rb
63
84
  - lib/syrma/rspec.rb
64
85
  - lib/syrma/script_launcher.rb