ruby_raider 3.9.0 → 3.11.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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +18 -29
  3. data/.github/workflows/security.yml +68 -0
  4. data/.rubocop.yml +28 -0
  5. data/README.md +23 -4
  6. data/docs/superpowers/plans/2026-10-25-failure-video-replay.md +400 -0
  7. data/docs/superpowers/plans/2026-10-26-parity-cleanup.md +233 -0
  8. data/docs/superpowers/plans/2026-10-28-failure-card.md +295 -0
  9. data/docs/superpowers/specs/2026-10-25-failure-video-replay-design.md +214 -0
  10. data/docs/superpowers/specs/2026-10-28-failure-card-design.md +210 -0
  11. data/lib/commands/scaffolding_commands.rb +3 -3
  12. data/lib/commands/utility_commands.rb +36 -6
  13. data/lib/generators/cucumber/templates/cucumber.tt +1 -1
  14. data/lib/generators/cucumber/templates/feature.tt +5 -0
  15. data/lib/generators/cucumber/templates/partials/appium_env.tt +6 -0
  16. data/lib/generators/cucumber/templates/partials/selenium_env.tt +22 -0
  17. data/lib/generators/cucumber/templates/partials/watir_env.tt +22 -0
  18. data/lib/generators/cucumber/templates/partials/web_steps.tt +4 -0
  19. data/lib/generators/helper_generator.rb +12 -1
  20. data/lib/generators/infrastructure/azure_generator.rb +1 -0
  21. data/lib/generators/infrastructure/github_generator.rb +1 -0
  22. data/lib/generators/infrastructure/templates/azure_pipelines.tt +12 -1
  23. data/lib/generators/infrastructure/templates/github.tt +12 -0
  24. data/lib/generators/infrastructure/templates/github_appium.tt +23 -0
  25. data/lib/generators/invoke_generators.rb +20 -2
  26. data/lib/generators/rspec/rspec_generator.rb +0 -1
  27. data/lib/generators/rspec/templates/spec.tt +12 -0
  28. data/lib/generators/templates/common/gemfile.tt +1 -1
  29. data/lib/generators/templates/common/git_ignore.tt +7 -1
  30. data/lib/generators/templates/common/partials/mobile_config.tt +5 -0
  31. data/lib/generators/templates/common/partials/web_config.tt +9 -1
  32. data/lib/generators/templates/common/read_me.tt +84 -2
  33. data/lib/generators/templates/helpers/browser_helper.tt +5 -1
  34. data/lib/generators/templates/helpers/driver_helper.tt +1 -0
  35. data/lib/generators/templates/helpers/partials/selenium_driver.tt +4 -1
  36. data/lib/generators/templates/helpers/session_state.tt +206 -0
  37. data/lib/generators/templates/helpers/spec_helper.tt +28 -0
  38. data/lib/generators/templates/helpers/video_helper.tt +338 -171
  39. data/lib/ruby_raider/ffmpeg.rb +280 -0
  40. data/lib/ruby_raider.rb +2 -3
  41. data/lib/scaffolding/project_detector.rb +1 -3
  42. data/lib/scaffolding/scaffolding.rb +0 -1
  43. data/lib/utilities/utilities.rb +15 -2
  44. data/lib/version +1 -1
  45. data/ruby_raider.gemspec +4 -1
  46. data/sig/commands/utility_commands.rbs +4 -1
  47. data/sig/generators/invoke_generators.rbs +2 -3
  48. data/sig/ruby_raider/ffmpeg.rbs +23 -0
  49. data/sig/utilities/utilities.rbs +1 -0
  50. data/spec/commands/raider_commands_spec.rb +56 -1
  51. data/spec/generators/fixtures/video_helper_stubs/selenium-webdriver.rb +28 -0
  52. data/spec/generators/video_helper_spec.rb +608 -0
  53. data/spec/integration/commands/browser_update_after_creation_spec.rb +7 -6
  54. data/spec/integration/commands/utility_commands_spec.rb +2 -2
  55. data/spec/integration/content/azure_content_spec.rb +33 -0
  56. data/spec/integration/content/ci_content_spec.rb +54 -0
  57. data/spec/integration/content/common_content_spec.rb +35 -0
  58. data/spec/integration/content/config_content_spec.rb +32 -0
  59. data/spec/integration/content/cucumber_reporter_content_spec.rb +47 -1
  60. data/spec/integration/content/gemfile_content_spec.rb +2 -3
  61. data/spec/integration/content/helper_content_spec.rb +178 -0
  62. data/spec/integration/content/page_content_spec.rb +0 -1
  63. data/spec/integration/content/readme_content_spec.rb +79 -0
  64. data/spec/integration/content/rspec_reporter_content_spec.rb +2 -2
  65. data/spec/integration/content/syntax_validation_spec.rb +15 -0
  66. data/spec/integration/content/test_content_spec.rb +27 -3
  67. data/spec/integration/end_to_end_features_spec.rb +0 -2
  68. data/spec/integration/end_to_end_spec.rb +275 -26
  69. data/spec/integration/generators/azure_generator_spec.rb +7 -6
  70. data/spec/integration/generators/common_generator_spec.rb +1 -1
  71. data/spec/integration/generators/helpers_generator_spec.rb +22 -0
  72. data/spec/integration/generators/invoke_generators_spec.rb +52 -0
  73. data/spec/integration/helpers/session_state_spec.rb +428 -0
  74. data/spec/integration/scaffolding_e2e_spec.rb +13 -24
  75. data/spec/scaffolding/scaffold_project_detector_spec.rb +0 -1
  76. data/spec/scaffolding/scaffolding_features_spec.rb +0 -1
  77. data/spec/system/selenium_spec.rb +0 -1
  78. data/spec/system/watir_spec.rb +0 -1
  79. data/spec/utilities/ffmpeg_spec.rb +424 -0
  80. data/spec/utilities/utilities_spec.rb +19 -2
  81. data/spec/utilities/video_command_spec.rb +120 -0
  82. metadata +65 -11
  83. data/lib/commands/adopt_commands.rb +0 -42
  84. data/lib/commands/loaded_commands.rb +0 -9
  85. data/lib/commands/plugin_commands.rb +0 -32
  86. data/sig/commands/adopt_commands.rbs +0 -8
  87. data/sig/commands/loaded_commands.rbs +0 -5
  88. data/sig/commands/plugin_commands.rbs +0 -9
@@ -1,270 +1,437 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'fileutils'
4
+ <%- unless mobile? -%>
5
+ require 'open3'
6
+ require 'selenium-webdriver'
7
+ <%- end -%>
8
+ require 'tmpdir'
4
9
  require 'yaml'
5
- require 'json'
6
- require 'base64'
7
10
 
11
+ # Records each test and keeps the video only when the test fails.
12
+ <%- if mobile? -%>
13
+ # Appium records the device screen natively (mp4).
14
+ <%- else -%>
15
+ # Web tests capture a page screenshot after each navigation, click and text
16
+ # change (FrameListener), plus one final frame, and only failing tests are
17
+ # encoded to WebM with the ffmpeg provisioned by the ruby_raider gem.
18
+ # When no ffmpeg can be obtained, recording is off from the first driver on.
19
+ <%- end -%>
20
+ # Any recorder error prints one [video] warning and turns recording off for
21
+ # the rest of the run; it never raises into a test. VIDEO=off skips recording.
8
22
  module VideoHelper
9
- VIDEO_DIR = 'videos'
23
+ DEFAULTS = { 'enabled' => true, 'fps' => 5, 'max_width' => 1280 }.freeze
24
+ <%- unless mobile? -%>
25
+ MAX_FRAME_SECONDS = 2.0
26
+ ENCODE_TIMEOUT = 120
27
+ NO_FFMPEG = 'no ffmpeg available for this platform yet (set RAIDER_FFMPEG_PATH, or run ' \
28
+ '`raider u video install` once the ffmpeg release is published)'
29
+ <%- end -%>
10
30
 
11
- module_function
31
+ @disabled = false
32
+ @warned = false
33
+ <%- unless mobile? -%>
34
+ @active_recorder = nil
35
+ @ffmpeg_obtainable = nil
36
+ <%- end -%>
12
37
 
13
- def config
14
- @config ||= begin
15
- yml = YAML.load_file('config/config.yml')
16
- yml['video'] || {}
17
- rescue StandardError
18
- {}
38
+ class << self
39
+ <%- unless mobile? -%>
40
+ attr_accessor :active_recorder
41
+
42
+ <%- end -%>
43
+ def config
44
+ @config ||= DEFAULTS.merge(video_config)
19
45
  end
20
- end
46
+ <%- if mobile? -%>
21
47
 
22
- def enabled?
23
- config.fetch('enabled', false)
24
- end
48
+ # Seconds of device recording per test; video.time_limit in config.yml overrides.
49
+ def time_limit
50
+ configured = config['time_limit'].to_i
51
+ return configured if configured.positive?
25
52
 
26
- def recorder_for(driver_or_browser)
27
- return NullRecorder.new unless enabled?
53
+ limits = AppiumRecorder::TIME_LIMITS
54
+ limits.fetch(platform, limits['android'])
55
+ end
28
56
 
29
- strategy = config.fetch('strategy', 'auto')
30
- case strategy
31
- when 'cdp' then CdpRecorder.new(resolve_selenium_driver(driver_or_browser))
32
- when 'screen' then ScreenCaptureRecorder.new
33
- <%- if mobile? -%>
34
- when 'appium' then AppiumRecorder.new(driver_or_browser)
57
+ def platform
58
+ <%- if cross_platform? -%>
59
+ YAML.load_file('config/config.yml')['platform'].to_s.delete_prefix(':')
60
+ rescue StandardError
61
+ ''
62
+ <%- else -%>
63
+ '<%= android? ? 'android' : 'ios' %>'
35
64
  <%- end -%>
36
- when 'auto' then auto_detect(driver_or_browser)
37
- else NullRecorder.new
38
65
  end
39
- end
66
+ <%- end -%>
67
+
68
+ def enabled?
69
+ config['enabled'] != false && ENV['VIDEO'] != 'off' && !@disabled
70
+ end
40
71
 
41
- def auto_detect(driver_or_browser)
42
- sel_driver = resolve_selenium_driver(driver_or_browser)
72
+ def recorder_for(driver_or_browser)
43
73
  <%- if mobile? -%>
44
- return AppiumRecorder.new(driver_or_browser) if defined?(Appium::Driver)
74
+ return NullRecorder.new unless enabled?
75
+ <%- else -%>
76
+ return NullRecorder.new unless enabled? && ffmpeg_obtainable?
45
77
  <%- end -%>
46
- return CdpRecorder.new(sel_driver) if chrome_or_edge?(sel_driver)
47
78
 
48
- if ffmpeg_available?
49
- ScreenCaptureRecorder.new
50
- else
79
+ <%- if mobile? -%>
80
+ AppiumRecorder.new(driver_or_browser)
81
+ <%- else -%>
82
+ FrameRecorder.new(selenium_driver(driver_or_browser))
83
+ <%- end -%>
84
+ rescue StandardError => e
85
+ disable!("could not create a recorder: #{e.message}")
51
86
  NullRecorder.new
52
87
  end
53
- end
54
88
 
55
- def resolve_selenium_driver(obj)
56
- if obj.respond_to?(:driver) && !obj.is_a?(Selenium::WebDriver::Driver)
57
- obj.driver
58
- elsif obj.respond_to?(:browser) && obj.browser.respond_to?(:driver)
59
- obj.browser.driver
60
- else
61
- obj
89
+ # True when the example passed or no retry is left, so a failed attempt
90
+ # that will be retried does not pay for an encode. rspec-retry sets
91
+ # metadata[:retry_attempts] (0-based) before each attempt, and after(:each)
92
+ # hooks run inside that attempt.
93
+ def final_attempt?(example)
94
+ return true unless example.exception
95
+
96
+ example.metadata[:retry_attempts].to_i + 1 >= retry_count(example)
62
97
  end
63
- rescue StandardError
64
- obj
65
- end
66
98
 
67
- def chrome_or_edge?(driver)
68
- browser_name = driver.capabilities[:browser_name].to_s.downcase
69
- %w[chrome chromium msedge edge].any? { |b| browser_name.include?(b) }
70
- rescue StandardError
71
- false
72
- end
99
+ # A nil reason means the cause was already reported (by RubyRaider::Ffmpeg).
100
+ def disable!(reason = nil)
101
+ @disabled = true
102
+ <%- unless mobile? -%>
103
+ @active_recorder = nil
104
+ <%- end -%>
105
+ return nil if @warned
73
106
 
74
- def ffmpeg_available?
75
- system('ffmpeg -version > /dev/null 2>&1') ||
76
- system('ffmpeg -version > NUL 2>&1')
77
- end
107
+ @warned = true
108
+ say("#{reason}; recording disabled for this run") if reason
109
+ nil
110
+ end
111
+ <%- unless mobile? -%>
78
112
 
79
- def ensure_video_dir
80
- FileUtils.mkdir_p(VIDEO_DIR)
81
- end
113
+ # Passed to Selenium as `listener:` when the driver is created.
114
+ def listener
115
+ return nil unless enabled? && ffmpeg_obtainable?
116
+
117
+ @listener ||= FrameListener.new
118
+ end
82
119
 
83
- # --- CDP Recorder: Chrome/Edge, works headless ---
120
+ def release(recorder)
121
+ @active_recorder = nil if @active_recorder.equal?(recorder)
122
+ end
84
123
 
85
- class CdpRecorder
86
- def initialize(driver)
87
- @driver = driver
88
- @frames = []
89
- @recording = false
124
+ def fps
125
+ value = config['fps'].to_f
126
+ value.positive? ? value : DEFAULTS['fps']
90
127
  end
91
128
 
92
- def start(test_name)
93
- @test_name = sanitize(test_name)
94
- @frames = []
95
- @frame_index = 0
96
- @recording = true
97
- @driver.execute_cdp('Page.startScreencast',
98
- format: 'jpeg', quality: 60,
99
- maxWidth: 1280, maxHeight: 720,
100
- everyNthFrame: 2)
101
- start_frame_collector
102
- rescue StandardError => e
103
- warn "[video] CDP recording failed to start: #{e.message}"
104
- @recording = false
129
+ def max_width
130
+ value = config['max_width'].to_i
131
+ value.positive? ? value : DEFAULTS['max_width']
105
132
  end
106
133
 
107
- def stop
108
- return nil unless @recording
134
+ def monotonic_now
135
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
136
+ end
109
137
 
110
- @recording = false
111
- @driver.execute_cdp('Page.stopScreencast')
112
- sleep 0.2 # allow final frames to arrive
113
- assemble_video
114
- rescue StandardError => e
115
- warn "[video] CDP recording failed to stop: #{e.message}"
116
- nil
138
+ # A screenshot while an alert is open would dismiss it, so skip that frame.
139
+ # An alert opened between this check and the screenshot can still be hit.
140
+ def alert_open?(driver)
141
+ driver.switch_to.alert
142
+ true
143
+ rescue Selenium::WebDriver::Error::NoSuchAlertError
144
+ false
145
+ end
146
+
147
+ # frames: [[file name, monotonic time], ...] inside dir. Returns the video path or nil.
148
+ def encode(dir, frames)
149
+ ffmpeg = ffmpeg_path
150
+ return nil unless ffmpeg
151
+
152
+ list = File.join(dir, 'list.txt')
153
+ File.write(list, concat_list(frames))
154
+ output = File.join(dir, 'video.webm')
155
+ ok, log = run_ffmpeg(ffmpeg_arguments(ffmpeg, list, output))
156
+ return output if ok && File.size?(output)
157
+
158
+ disable!("ffmpeg could not encode the video: #{log.to_s.strip.lines.last || 'no output'}")
117
159
  end
160
+ <%- end -%>
118
161
 
119
162
  private
120
163
 
121
- def start_frame_collector
122
- @collector = Thread.new do
123
- while @recording
124
- begin
125
- collect_frame
126
- rescue StandardError
127
- sleep 0.1
128
- end
129
- sleep 0.05
130
- end
164
+ def video_config
165
+ video = YAML.load_file('config/config.yml')['video']
166
+ video.is_a?(Hash) ? video : {}
167
+ rescue StandardError
168
+ {}
169
+ end
170
+
171
+ # Same precedence as rspec-retry, which never retries fewer than once.
172
+ def retry_count(example)
173
+ configured = ENV['RSPEC_RETRY_RETRY_COUNT'] || example.metadata[:retry] || default_retry_count
174
+ [configured.to_i, 1].max
175
+ end
176
+
177
+ def default_retry_count
178
+ RSpec.configuration.respond_to?(:default_retry_count) ? RSpec.configuration.default_retry_count : 0
179
+ end
180
+
181
+ # $stderr.puts, not Kernel#warn: warn is silenced under -W0.
182
+ # rubocop:disable Style/StderrPuts
183
+ def say(message)
184
+ $stderr.puts("[video] #{message}")
185
+ end
186
+ # rubocop:enable Style/StderrPuts
187
+ <%- unless mobile? -%>
188
+
189
+ # Watir::Browser wraps the Selenium driver.
190
+ def selenium_driver(driver_or_browser)
191
+ driver_or_browser.respond_to?(:wd) ? driver_or_browser.wd : driver_or_browser
192
+ end
193
+
194
+ # Decided once per process, at the first listener/recorder_for call, so a run
195
+ # that can never get an ffmpeg warns at the start and pays no capture cost.
196
+ def ffmpeg_obtainable?
197
+ return @ffmpeg_obtainable unless @ffmpeg_obtainable.nil?
198
+
199
+ @ffmpeg_obtainable = check_ffmpeg_obtainable
200
+ end
201
+
202
+ def check_ffmpeg_obtainable
203
+ require 'ruby_raider/ffmpeg'
204
+ return true if RubyRaider::Ffmpeg.obtainable?
205
+
206
+ disable!(NO_FFMPEG)
207
+ false
208
+ rescue LoadError, NoMethodError
209
+ disable!('ruby_raider gem too old for video; update it')
210
+ false
211
+ end
212
+
213
+ def ffmpeg_path
214
+ require 'ruby_raider/ffmpeg'
215
+ RubyRaider::Ffmpeg.ensure! || disable!
216
+ rescue LoadError
217
+ disable!('ruby_raider gem too old for video; update it')
218
+ end
219
+
220
+ # Concat demuxer list: each frame lasts until the next one, clamped to
221
+ # [1/fps, MAX_FRAME_SECONDS], so playback follows the test's real time.
222
+ def concat_list(frames)
223
+ shortest = 1.0 / fps
224
+ lines = frames.each_with_index.flat_map do |(name, time), index|
225
+ following = frames[index + 1]
226
+ duration = following ? (following[1] - time).clamp(shortest, MAX_FRAME_SECONDS) : shortest
227
+ ["file '#{name}'", format('duration %.3f', duration)]
131
228
  end
229
+ (lines << "file '#{frames.last[0]}'").join("\n") << "\n"
132
230
  end
133
231
 
134
- def collect_frame
135
- # CDP screencast events are handled by polling via execute_cdp
136
- # We capture screenshots at intervals as a reliable alternative
137
- VideoHelper.ensure_video_dir
138
- frame_path = File.join(VideoHelper::VIDEO_DIR, "#{@test_name}_frame_#{format('%04d', @frame_index)}.jpg")
139
- @driver.save_screenshot(frame_path)
140
- @frames << frame_path
141
- @frame_index += 1
232
+ def ffmpeg_arguments(ffmpeg, list, output)
233
+ [ffmpeg, '-y', '-loglevel', 'error', '-f', 'concat', '-safe', '0', '-i', list,
234
+ '-vf', "scale='min(#{max_width},iw)':-2", '-c:v', 'libvpx', '-deadline', 'realtime',
235
+ '-b:v', '1M', '-r', format('%g', fps), '-pix_fmt', 'yuv420p', output]
142
236
  end
143
237
 
144
- def assemble_video
145
- return nil if @frames.empty?
238
+ # ffmpeg may exit between the join timeout and the kill.
239
+ def kill(process)
240
+ Process.kill('KILL', process.pid)
241
+ rescue Errno::ESRCH
242
+ nil
243
+ end
146
244
 
147
- @collector&.join(2)
148
- VideoHelper.ensure_video_dir
149
- output = File.join(VideoHelper::VIDEO_DIR, "#{@test_name}.mp4")
150
- pattern = File.join(VideoHelper::VIDEO_DIR, "#{@test_name}_frame_%04d.jpg")
245
+ def run_ffmpeg(arguments)
246
+ Open3.popen2e(*arguments) do |stdin, output, process|
247
+ stdin.close
248
+ reader = Thread.new { output.read }
249
+ reader.report_on_exception = false
250
+ return [process.value.success?, reader.value] if process.join(ENCODE_TIMEOUT)
251
+
252
+ kill(process)
253
+ process.value
254
+ reader.join(1)
255
+ [false, "ffmpeg timed out after #{ENCODE_TIMEOUT} seconds"]
256
+ end
257
+ end
258
+ <%- end -%>
259
+ end
260
+ <%- unless mobile? -%>
151
261
 
152
- system("ffmpeg -y -framerate 10 -i \"#{pattern}\" -c:v libx264 -pix_fmt yuv420p " \
153
- "-movflags +faststart -loglevel error \"#{output}\"")
262
+ # Forwards the state-changing Selenium events to the active FrameRecorder.
263
+ # One recorded driver per process: every frame goes to the active recorder.
264
+ class FrameListener < Selenium::WebDriver::Support::AbstractEventListener
265
+ def after_navigate_to(_url, driver)
266
+ capture(driver)
267
+ end
154
268
 
155
- cleanup_frames
156
- File.exist?(output) ? output : nil
269
+ def after_click(_element, driver)
270
+ capture(driver)
157
271
  end
158
272
 
159
- def cleanup_frames
160
- @frames.each { |f| FileUtils.rm_f(f) }
273
+ def after_change_value_of(_element, driver)
274
+ capture(driver)
161
275
  end
162
276
 
163
- def sanitize(name)
164
- name.gsub(/[^a-zA-Z0-9_-]/, '_')[0..80]
277
+ private
278
+
279
+ def capture(driver)
280
+ VideoHelper.active_recorder&.capture(driver)
281
+ nil
282
+ rescue StandardError => e
283
+ VideoHelper.disable!("frame capture failed: #{e.message}")
165
284
  end
166
285
  end
167
286
 
168
- # --- Screen Capture Recorder: Any browser, requires FFmpeg + display ---
287
+ # Saves frames to its own tmpdir; encodes them only when the test failed.
288
+ class FrameRecorder
289
+ attr_reader :dir
169
290
 
170
- class ScreenCaptureRecorder
171
- def initialize
291
+ def initialize(driver)
292
+ @driver = driver
293
+ @frames = []
172
294
  @recording = false
173
295
  end
174
296
 
175
- def start(test_name)
176
- @test_name = sanitize(test_name)
177
- VideoHelper.ensure_video_dir
178
- @output = File.join(VideoHelper::VIDEO_DIR, "#{@test_name}.mp4")
297
+ def start
298
+ @dir = Dir.mktmpdir('raider-video')
299
+ @frames = []
179
300
  @recording = true
301
+ VideoHelper.active_recorder = self
302
+ nil
303
+ rescue StandardError => e
304
+ VideoHelper.disable!("recording failed to start: #{e.message}")
305
+ discard
306
+ end
180
307
 
181
- input = screen_input
182
- @pid = spawn("ffmpeg -y #{input} -r 15 -c:v libx264 -pix_fmt yuv420p " \
183
- "-loglevel error \"#{@output}\"",
184
- %i[out err] => '/dev/null')
308
+ def capture(driver = @driver)
309
+ return nil unless @recording && VideoHelper.enabled?
310
+
311
+ snap(driver)
185
312
  rescue StandardError => e
186
- warn "[video] Screen recording failed to start: #{e.message}"
187
- @recording = false
313
+ VideoHelper.disable!("frame capture failed: #{e.message}")
188
314
  end
189
315
 
190
- def stop
316
+ def stop(keep:)
191
317
  return nil unless @recording
192
318
 
193
- @recording = false
194
- Process.kill('INT', @pid)
195
- Process.wait(@pid)
196
- File.exist?(@output) ? @output : nil
319
+ final_frame if keep
320
+ finish
321
+ return discard unless keep && VideoHelper.enabled? && @frames.any?
322
+
323
+ VideoHelper.encode(@dir, @frames) || discard
197
324
  rescue StandardError => e
198
- warn "[video] Screen recording failed to stop: #{e.message}"
325
+ finish
326
+ VideoHelper.disable!("recording failed to stop: #{e.message}")
327
+ discard
328
+ end
329
+
330
+ def cleanup
331
+ FileUtils.rm_rf(@dir) if @dir
199
332
  nil
333
+ rescue StandardError => e
334
+ VideoHelper.disable!("could not remove video frames: #{e.message}")
200
335
  end
201
336
 
202
337
  private
203
338
 
204
- def screen_input
205
- case RbConfig::CONFIG['host_os']
206
- when /darwin/i
207
- '-f avfoundation -i "1:none"'
208
- when /linux/i
209
- display = ENV.fetch('DISPLAY', ':99')
210
- "-f x11grab -i #{display}"
211
- when /mswin|mingw|cygwin/i
212
- '-f gdigrab -i desktop'
213
- else
214
- '-f x11grab -i :0'
215
- end
339
+ def snap(driver)
340
+ return nil if VideoHelper.alert_open?(driver)
341
+
342
+ name = format('frame-%06d.png', @frames.size + 1)
343
+ time = VideoHelper.monotonic_now
344
+ driver.save_screenshot(File.join(@dir, name))
345
+ @frames << [name, time]
346
+ nil
216
347
  end
217
348
 
218
- def sanitize(name)
219
- name.gsub(/[^a-zA-Z0-9_-]/, '_')[0..80]
349
+ # The browser is often gone after a failure; keep the frames already saved.
350
+ def final_frame
351
+ snap(@driver) if VideoHelper.enabled?
352
+ rescue StandardError
353
+ nil
220
354
  end
221
- end
222
355
 
223
- <%- if mobile? -%>
224
- # --- Appium Recorder: Mobile, uses native API ---
356
+ def finish
357
+ @recording = false
358
+ VideoHelper.release(self)
359
+ end
360
+
361
+ def discard
362
+ cleanup
363
+ nil
364
+ end
365
+ end
366
+ <%- else -%>
225
367
 
368
+ # Appium's native screen recording; the mp4 is written as-is. It needs ffmpeg on
369
+ # the Appium server host for iOS, and for Android recordings over 180 s.
226
370
  class AppiumRecorder
371
+ # Android keeps Appium's 180 s default so it works without ffmpeg on the Appium host.
372
+ TIME_LIMITS = { 'ios' => 1800, 'android' => 180 }.freeze
373
+
374
+ attr_reader :dir
375
+
227
376
  def initialize(driver)
228
- @driver = driver
377
+ # appium_lib's Appium::Driver keeps the recording API on its core driver.
378
+ @driver = driver.respond_to?(:start_recording_screen) ? driver : driver.driver
229
379
  @recording = false
230
380
  end
231
381
 
232
- def start(test_name)
233
- @test_name = sanitize(test_name)
382
+ def start
383
+ @dir = Dir.mktmpdir('raider-video')
384
+ @driver.start_recording_screen(time_limit: VideoHelper.time_limit)
234
385
  @recording = true
235
- @driver.start_recording_screen
386
+ nil
236
387
  rescue StandardError => e
237
- warn "[video] Appium recording failed to start: #{e.message}"
238
- @recording = false
388
+ VideoHelper.disable!("recording failed to start: #{e.message}")
389
+ discard
239
390
  end
240
391
 
241
- def stop
392
+ def stop(keep:)
242
393
  return nil unless @recording
243
394
 
244
395
  @recording = false
245
- video_base64 = @driver.stop_recording_screen
246
- VideoHelper.ensure_video_dir
247
- output = File.join(VideoHelper::VIDEO_DIR, "#{@test_name}.mp4")
248
- File.write(output, Base64.decode64(video_base64), mode: 'wb')
249
- output
396
+ video = @driver.stop_recording_screen
397
+ return discard unless keep && VideoHelper.enabled? && video && !video.empty?
398
+
399
+ path = File.join(@dir, 'video.mp4')
400
+ File.binwrite(path, video.unpack1('m'))
401
+ path
250
402
  rescue StandardError => e
251
- warn "[video] Appium recording failed to stop: #{e.message}"
403
+ VideoHelper.disable!("recording failed to stop: #{e.message}")
404
+ discard
405
+ end
406
+
407
+ def cleanup
408
+ FileUtils.rm_rf(@dir) if @dir
252
409
  nil
410
+ rescue StandardError => e
411
+ VideoHelper.disable!("could not remove the video: #{e.message}")
253
412
  end
254
413
 
255
414
  private
256
415
 
257
- def sanitize(name)
258
- name.gsub(/[^a-zA-Z0-9_-]/, '_')[0..80]
416
+ def discard
417
+ cleanup
418
+ nil
259
419
  end
260
420
  end
261
-
262
421
  <%- end -%>
263
- # --- Null Recorder: Fallback when recording unavailable ---
264
422
 
423
+ # Used when recording is off or unavailable.
265
424
  class NullRecorder
266
- def start(_test_name) = nil
425
+ def start
426
+ nil
427
+ end
428
+
429
+ def stop(**)
430
+ nil
431
+ end
267
432
 
268
- def stop = nil
433
+ def cleanup
434
+ nil
435
+ end
269
436
  end
270
437
  end