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 +4 -4
- data/CHANGELOG.md +3 -1
- data/README.md +21 -0
- data/lib/syrma/recording.rb +401 -0
- data/lib/syrma/session.rb +19 -0
- data/lib/syrma/version.rb +1 -1
- data/lib/syrma.rb +1 -0
- data/sig/syrma.rbs +32 -0
- metadata +23 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b76bd8cacebc9d29ee6b28a913efb6ef5ec7283cdf6462279064343207925a71
|
|
4
|
+
data.tar.gz: 5ee18a3987ecee83fd8c2a334afee51c1a161f3e892c155d0a9b1ecda3e0e68a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8166b829a204806d7c53c101bebab2b1568e95d2af3756510aa85eecf76a9234602b656a316388ce5f0c2f09b4e949b6ba18076aaabf8b2b9e2146ba69d88348
|
|
7
|
+
data.tar.gz: 11694f50f19407679166aa2cc305c1418832a3075166e76d9153cbd17b31996989f7211bd7b9997ba9f8818903a17147f5342196cd6d2ff4a4f7d9aa4ef6214f
|
data/CHANGELOG.md
CHANGED
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
|
+

|
|
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
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.
|
|
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-
|
|
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
|