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
@@ -0,0 +1,608 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'erb'
5
+ require 'fileutils'
6
+ require 'stringio'
7
+ require 'tmpdir'
8
+ require 'yaml'
9
+ require_relative '../../lib/generators/helper_generator'
10
+
11
+ $LOAD_PATH.unshift(File.expand_path('fixtures/video_helper_stubs', __dir__))
12
+ require 'selenium-webdriver'
13
+
14
+ # Renders the real video_helper.tt for a project type, loads it, and drives it
15
+ # with doubles. ffmpeg is a fake shell script given through RAIDER_FFMPEG_PATH.
16
+ # rubocop:disable RSpec/MultipleMemoizedHelpers, RSpec/VerifiedDoubles, RSpec/ExpectOutput, RSpec/MultipleExpectations
17
+ describe 'Generated VideoHelper' do
18
+ let(:template) { File.expand_path('../../lib/generators/templates/helpers/video_helper.tt', __dir__) }
19
+ let(:png) { Base64.decode64('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==') }
20
+ let(:project_dir) { Dir.mktmpdir('video-helper-project') }
21
+ let(:video_config) { { 'enabled' => true, 'fps' => 5, 'max_width' => 1280 } }
22
+ let(:config_yml) { { 'browser' => 'chrome', 'video' => video_config } }
23
+ let(:automation) { 'selenium' }
24
+ let(:ffmpeg_mode) { :ok }
25
+ let(:ffmpeg_body) do
26
+ {
27
+ ok: %(printf 'webm-bytes' > "$last"),
28
+ fail: 'echo "boom" >&2; exit 1',
29
+ empty: %(: > "$last"),
30
+ hang: 'exec sleep 30'
31
+ }.fetch(ffmpeg_mode)
32
+ end
33
+ let(:fake_ffmpeg) do
34
+ path = File.join(project_dir, 'fake-ffmpeg')
35
+ File.write(path, <<~SH)
36
+ #!/bin/sh
37
+ log_dir='#{project_dir}'
38
+ printf '%s\\n' "$@" > "$log_dir/argv.txt"
39
+ prev=''
40
+ for arg in "$@"; do
41
+ if [ "$prev" = "-i" ]; then cp "$arg" "$log_dir/list.txt"; fi
42
+ prev="$arg"
43
+ last="$arg"
44
+ done
45
+ #{ffmpeg_body}
46
+ SH
47
+ File.chmod(0o755, path)
48
+ path
49
+ end
50
+ let(:env) { { 'RAIDER_FFMPEG_PATH' => fake_ffmpeg } }
51
+ let(:tmp) { File.join(project_dir, 'tmp').tap { |dir| FileUtils.mkdir_p(dir) } }
52
+ let(:argv) { File.exist?(argv_path) ? File.read(argv_path).lines(chomp: true) : nil }
53
+ let(:argv_path) { File.join(project_dir, 'argv.txt') }
54
+ let(:alerts) { double('target_locator') }
55
+ let(:driver) { double('driver', switch_to: alerts) }
56
+ let(:shots) { [] }
57
+ let(:stderr) { StringIO.new }
58
+ let(:warnings) { stderr.string.lines.grep(/\[video\]/) }
59
+
60
+ def render_and_load
61
+ generator = HelpersGenerator.new([automation, 'rspec', 'proj'])
62
+ source = ERB.new(File.read(template), trim_mode: '-').result(generator.instance_eval { binding })
63
+ path = File.join(project_dir, 'video_helper.rb')
64
+ File.write(path, source)
65
+ load path
66
+ end
67
+
68
+ def frame_files(recorder)
69
+ Dir.glob(File.join(recorder.dir, 'frame-*.png'))
70
+ end
71
+
72
+ around do |example|
73
+ original_stderr = $stderr
74
+ $stderr = stderr
75
+ FileUtils.mkdir_p(File.join(project_dir, 'config'))
76
+ File.write(File.join(project_dir, 'config', 'config.yml'), config_yml.to_yaml)
77
+ Dir.chdir(project_dir) { example.run }
78
+ ensure
79
+ $stderr = original_stderr
80
+ Object.send(:remove_const, :VideoHelper) if defined?(VideoHelper)
81
+ FileUtils.rm_rf(project_dir)
82
+ end
83
+
84
+ before do
85
+ # Recorder tmpdirs land inside the project, which the around hook removes.
86
+ stub_const('ENV', ENV.to_h.merge(env).merge('TMPDIR' => tmp).compact)
87
+ allow(alerts).to receive(:alert).and_raise(Selenium::WebDriver::Error::NoSuchAlertError)
88
+ allow(driver).to receive(:save_screenshot) do |path|
89
+ shots << path
90
+ File.binwrite(path, png)
91
+ end
92
+ render_and_load
93
+ end
94
+
95
+ context 'with a selenium project' do
96
+ it 'stop(keep: false) never invokes ffmpeg and returns nil' do
97
+ recorder = VideoHelper.recorder_for(driver)
98
+ recorder.start
99
+ VideoHelper.listener.after_click(:element, driver)
100
+
101
+ expect(recorder.stop(keep: false)).to be_nil
102
+ expect(argv).to be_nil
103
+ expect(shots.size).to eq(1)
104
+ expect(warnings).to be_empty
105
+ end
106
+
107
+ it 'stop(keep: true) invokes ffmpeg with libvpx and returns <tmpdir>/video.webm' do
108
+ recorder = VideoHelper.recorder_for(driver)
109
+ recorder.start
110
+ VideoHelper.listener.after_navigate_to('https://example.test', driver)
111
+
112
+ path = recorder.stop(keep: true)
113
+
114
+ expect(path).to eq(File.join(recorder.dir, 'video.webm'))
115
+ expect(File.size(path)).to be_positive
116
+ expect(argv.each_cons(2).to_a).to include(%w[-c:v libvpx], %w[-deadline realtime], %w[-b:v 1M],
117
+ %w[-f concat], %w[-safe 0], %w[-r 5], %w[-pix_fmt yuv420p])
118
+ expect(argv.last).to eq(path)
119
+ expect(shots.size).to eq(2) # navigate + final frame
120
+ expect(warnings).to be_empty
121
+ end
122
+
123
+ it "scales frames to max_width via ffmpeg -vf scale='min(1280,iw)':-2" do
124
+ recorder = VideoHelper.recorder_for(driver)
125
+ recorder.start
126
+ recorder.stop(keep: true)
127
+
128
+ expect(argv.each_cons(2).to_a).to include(['-vf', "scale='min(1280,iw)':-2"])
129
+ end
130
+
131
+ context 'when the config sets other fps and max_width' do
132
+ let(:video_config) { { 'enabled' => true, 'fps' => 10, 'max_width' => 640 } }
133
+
134
+ it 'uses them for the output rate and the scale filter' do
135
+ recorder = VideoHelper.recorder_for(driver)
136
+ recorder.start
137
+ recorder.stop(keep: true)
138
+
139
+ expect(argv.each_cons(2).to_a).to include(['-vf', "scale='min(640,iw)':-2"], %w[-r 10])
140
+ end
141
+ end
142
+
143
+ it 'writes a concat list timed by real gaps, clamped to [1/fps, 2.0], with the last file repeated' do
144
+ allow(VideoHelper).to receive(:monotonic_now).and_return(10.0, 10.05, 13.05, 13.55)
145
+ recorder = VideoHelper.recorder_for(driver)
146
+ recorder.start
147
+ listener = VideoHelper.listener
148
+ listener.after_navigate_to('https://example.test', driver)
149
+ listener.after_click(:element, driver)
150
+ listener.after_change_value_of(:element, driver)
151
+ recorder.stop(keep: true)
152
+
153
+ expect(File.read(File.join(project_dir, 'list.txt'))).to eq(<<~LIST)
154
+ file 'frame-000001.png'
155
+ duration 0.200
156
+ file 'frame-000002.png'
157
+ duration 2.000
158
+ file 'frame-000003.png'
159
+ duration 0.500
160
+ file 'frame-000004.png'
161
+ duration 0.200
162
+ file 'frame-000004.png'
163
+ LIST
164
+ end
165
+
166
+ it 'saves frames as frame-%06d.png in the recorder tmpdir' do
167
+ recorder = VideoHelper.recorder_for(driver)
168
+ recorder.start
169
+ VideoHelper.listener.after_click(:element, driver)
170
+
171
+ expect(frame_files(recorder).map { |f| File.basename(f) }).to eq(['frame-000001.png'])
172
+ end
173
+
174
+ it 'does not capture after execute_script or find' do
175
+ recorder = VideoHelper.recorder_for(driver)
176
+ recorder.start
177
+ VideoHelper.listener.after_execute_script('return 1', driver)
178
+ VideoHelper.listener.after_find(:css, 'a', driver)
179
+
180
+ expect(shots).to be_empty
181
+ end
182
+
183
+ it 'skips a frame without disabling when an alert is open, so the screenshot cannot dismiss it' do
184
+ allow(alerts).to receive(:alert).and_return(double('alert'))
185
+ recorder = VideoHelper.recorder_for(driver)
186
+ recorder.start
187
+ VideoHelper.listener.after_click(:element, driver)
188
+
189
+ expect(shots).to be_empty
190
+ expect(VideoHelper.enabled?).to be(true)
191
+ expect(warnings).to be_empty
192
+ end
193
+
194
+ context 'when ffmpeg exits non-zero' do
195
+ let(:ffmpeg_mode) { :fail }
196
+
197
+ it 'returns nil and warns once' do
198
+ recorder = VideoHelper.recorder_for(driver)
199
+ recorder.start
200
+ expect(recorder.stop(keep: true)).to be_nil
201
+ second = VideoHelper.recorder_for(driver)
202
+ second.start
203
+
204
+ expect(second.stop(keep: true)).to be_nil
205
+ expect(warnings.size).to eq(1)
206
+ expect(warnings.first).to match(/\A\[video\] .+; recording disabled for this run\n\z/)
207
+ expect(VideoHelper.enabled?).to be(false)
208
+ end
209
+ end
210
+
211
+ context 'when ffmpeg writes 0 bytes' do
212
+ let(:ffmpeg_mode) { :empty }
213
+
214
+ it 'returns nil, warns once and removes the tmpdir' do
215
+ recorder = VideoHelper.recorder_for(driver)
216
+ recorder.start
217
+
218
+ expect(recorder.stop(keep: true)).to be_nil
219
+ expect(warnings.size).to eq(1)
220
+ expect(File).not_to exist(recorder.dir)
221
+ end
222
+ end
223
+
224
+ context 'when ffmpeg hangs' do
225
+ let(:ffmpeg_mode) { :hang }
226
+
227
+ it 'kills it after the encode timeout, returns nil and warns once' do
228
+ stub_const('VideoHelper::ENCODE_TIMEOUT', 0.5)
229
+ recorder = VideoHelper.recorder_for(driver)
230
+ recorder.start
231
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
232
+
233
+ expect(recorder.stop(keep: true)).to be_nil
234
+ expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 5
235
+ expect(stderr.string).to eq("[video] ffmpeg could not encode the video: ffmpeg timed out after 0.5 seconds; recording disabled for this run\n")
236
+ end
237
+ end
238
+
239
+ context 'when ffmpeg exits between the join timeout and the kill' do
240
+ let(:ffmpeg_mode) { :hang }
241
+
242
+ it 'ignores the missing process and still reports the timeout once' do
243
+ stub_const('VideoHelper::ENCODE_TIMEOUT', 0.5)
244
+ # Kill for real so the hung process ends, then report it as already gone.
245
+ allow(Process).to receive(:kill).and_wrap_original do |original, *args|
246
+ original.call(*args)
247
+ raise Errno::ESRCH
248
+ end
249
+ recorder = VideoHelper.recorder_for(driver)
250
+ recorder.start
251
+
252
+ expect { expect(recorder.stop(keep: true)).to be_nil }.not_to raise_error
253
+ expect(stderr.string).to include('ffmpeg timed out after 0.5 seconds')
254
+ expect(warnings.size).to eq(1)
255
+ end
256
+ end
257
+
258
+ it 'still encodes the saved frames, quietly, when only the final frame fails' do
259
+ recorder = VideoHelper.recorder_for(driver)
260
+ recorder.start
261
+ VideoHelper.listener.after_click(:element, driver)
262
+ allow(driver).to receive(:save_screenshot).and_raise(Selenium::WebDriver::Error::WebDriverError, 'browser crashed')
263
+
264
+ expect(recorder.stop(keep: true)).to eq(File.join(recorder.dir, 'video.webm'))
265
+ expect(File.read(File.join(project_dir, 'list.txt')).scan('frame-000001.png').size).to eq(2)
266
+ expect(VideoHelper.enabled?).to be(true)
267
+ expect(warnings).to be_empty
268
+ end
269
+
270
+ it 'returns nil quietly when the final frame fails and no frames were saved' do
271
+ allow(driver).to receive(:save_screenshot).and_raise(Selenium::WebDriver::Error::WebDriverError, 'browser crashed')
272
+ recorder = VideoHelper.recorder_for(driver)
273
+ recorder.start
274
+
275
+ expect(recorder.stop(keep: true)).to be_nil
276
+ expect(File).not_to exist(recorder.dir)
277
+ expect(argv).to be_nil
278
+ expect(warnings).to be_empty
279
+ end
280
+
281
+ it 'returns nil, warns once, and does not raise when save_screenshot raises (driver quit)' do
282
+ allow(driver).to receive(:save_screenshot).and_raise(Selenium::WebDriver::Error::WebDriverError, 'invalid session id')
283
+ recorder = VideoHelper.recorder_for(driver)
284
+ recorder.start
285
+
286
+ expect { VideoHelper.listener.after_click(:element, driver) }.not_to raise_error
287
+ expect(recorder.stop(keep: true)).to be_nil
288
+ expect(warnings.size).to eq(1)
289
+ expect(warnings.first).to include('invalid session id')
290
+ expect(VideoHelper.recorder_for(driver)).to be_a(VideoHelper::NullRecorder)
291
+ end
292
+
293
+ it 'two recorders started at once use different tmpdirs' do
294
+ first = VideoHelper.recorder_for(driver)
295
+ second = VideoHelper.recorder_for(driver)
296
+ first.start
297
+ second.start
298
+
299
+ expect(first.dir).not_to eq(second.dir)
300
+ expect(first.dir).to start_with(tmp)
301
+ expect([first.dir, second.dir]).to all(satisfy { |dir| File.directory?(dir) })
302
+ end
303
+
304
+ it 'adds no frames from hooks after stop' do
305
+ recorder = VideoHelper.recorder_for(driver)
306
+ recorder.start
307
+ recorder.stop(keep: false)
308
+ VideoHelper.listener.after_click(:element, driver)
309
+
310
+ expect(shots).to be_empty
311
+ expect(VideoHelper.active_recorder).to be_nil
312
+ end
313
+
314
+ it 'listener does nothing and does not raise with no active recorder' do
315
+ expect { VideoHelper.listener.after_click(:element, driver) }.not_to raise_error
316
+ expect(shots).to be_empty
317
+ expect(warnings).to be_empty
318
+ end
319
+
320
+ it 'returns one process-wide listener' do
321
+ expect(VideoHelper.listener).to be_a(VideoHelper::FrameListener)
322
+ expect(VideoHelper.listener).to equal(VideoHelper.listener)
323
+ end
324
+
325
+ it 'cleanup removes the tmpdir and is safe to call twice' do
326
+ recorder = VideoHelper.recorder_for(driver)
327
+ recorder.start
328
+ recorder.stop(keep: true)
329
+ recorder.cleanup
330
+
331
+ expect(File).not_to exist(recorder.dir)
332
+ expect { recorder.cleanup }.not_to raise_error
333
+ expect(recorder.stop(keep: true)).to be_nil
334
+ end
335
+
336
+ it 'disable! warns once in the [video] format' do
337
+ VideoHelper.disable!('first reason')
338
+ VideoHelper.disable!('second reason')
339
+
340
+ expect(warnings).to eq(["[video] first reason; recording disabled for this run\n"])
341
+ end
342
+
343
+ it 'records frames of a Watir browser through its Selenium driver' do
344
+ browser = double('browser', wd: driver)
345
+ recorder = VideoHelper.recorder_for(browser)
346
+ recorder.start
347
+
348
+ expect(recorder.stop(keep: true)).to end_with('video.webm')
349
+ expect(shots.size).to eq(1)
350
+ end
351
+
352
+ context 'when the ruby_raider gem has no ffmpeg support' do
353
+ it 'disables video with an update hint' do
354
+ allow(VideoHelper).to receive(:require).with('ruby_raider/ffmpeg').and_raise(LoadError)
355
+ recorder = VideoHelper.recorder_for(driver)
356
+ recorder.start
357
+
358
+ expect(recorder.stop(keep: true)).to be_nil
359
+ expect(warnings).to eq(["[video] ruby_raider gem too old for video; update it; recording disabled for this run\n"])
360
+ end
361
+ end
362
+
363
+ context 'when no ffmpeg can be obtained (no override, placeholder manifest)' do
364
+ let(:env) { { 'RAIDER_FFMPEG_PATH' => nil } }
365
+
366
+ it 'decides once, at the first call: listener nil, NullRecorder, exactly one [video] line' do
367
+ require 'ruby_raider/ffmpeg'
368
+ allow(RubyRaider::Ffmpeg).to receive(:ensure!)
369
+
370
+ expect(VideoHelper.listener).to be_nil
371
+ expect(warnings.size).to eq(1)
372
+ expect(VideoHelper.recorder_for(driver)).to be_a(VideoHelper::NullRecorder)
373
+ expect(VideoHelper.listener).to be_nil
374
+ expect(warnings).to eq(['[video] no ffmpeg available for this platform yet (set RAIDER_FFMPEG_PATH, or run ' \
375
+ '`raider u video install` once the ffmpeg release is published); ' \
376
+ "recording disabled for this run\n"])
377
+ expect(RubyRaider::Ffmpeg).not_to have_received(:ensure!)
378
+ expect(shots).to be_empty
379
+ end
380
+ end
381
+
382
+ it 'checks Ffmpeg.obtainable? only once per process' do
383
+ require 'ruby_raider/ffmpeg'
384
+ allow(RubyRaider::Ffmpeg).to receive(:obtainable?).and_call_original
385
+ 3.times { VideoHelper.recorder_for(driver) }
386
+ VideoHelper.listener
387
+
388
+ expect(RubyRaider::Ffmpeg).to have_received(:obtainable?).once
389
+ expect(VideoHelper.enabled?).to be(true)
390
+ end
391
+
392
+ context 'when VIDEO=off and no ffmpeg can be obtained' do
393
+ let(:env) { { 'RAIDER_FFMPEG_PATH' => nil, 'VIDEO' => 'off' } }
394
+
395
+ it 'prints nothing' do
396
+ VideoHelper.listener
397
+ VideoHelper.recorder_for(driver)
398
+
399
+ expect(warnings).to be_empty
400
+ end
401
+ end
402
+
403
+ context 'when Ffmpeg.ensure! returns nil' do
404
+ it 'disables video without a second warning' do
405
+ require 'ruby_raider/ffmpeg'
406
+ allow(RubyRaider::Ffmpeg).to receive(:ensure!) do
407
+ $stderr.write("[video] ffmpeg build not published yet\n")
408
+ nil
409
+ end
410
+ recorder = VideoHelper.recorder_for(driver)
411
+ recorder.start
412
+
413
+ expect(recorder.stop(keep: true)).to be_nil
414
+ expect(warnings).to eq(["[video] ffmpeg build not published yet\n"])
415
+ expect(VideoHelper.enabled?).to be(false)
416
+ end
417
+ end
418
+
419
+ it 'never calls Ffmpeg.ensure! for a passing test' do
420
+ require 'ruby_raider/ffmpeg'
421
+ allow(RubyRaider::Ffmpeg).to receive(:ensure!)
422
+ recorder = VideoHelper.recorder_for(driver)
423
+ recorder.start
424
+ recorder.stop(keep: false)
425
+
426
+ expect(RubyRaider::Ffmpeg).not_to have_received(:ensure!)
427
+ end
428
+
429
+ context 'when VIDEO=off' do
430
+ let(:env) { { 'RAIDER_FFMPEG_PATH' => fake_ffmpeg, 'VIDEO' => 'off' } }
431
+
432
+ it 'makes recorder_for return a NullRecorder and listener nil' do
433
+ expect(VideoHelper.recorder_for(driver)).to be_a(VideoHelper::NullRecorder)
434
+ expect(VideoHelper.listener).to be_nil
435
+ end
436
+ end
437
+
438
+ context 'when video is disabled in config' do
439
+ let(:video_config) { { 'enabled' => false } }
440
+
441
+ it 'makes recorder_for return a NullRecorder' do
442
+ expect(VideoHelper.enabled?).to be(false)
443
+ expect(VideoHelper.recorder_for(driver)).to be_a(VideoHelper::NullRecorder)
444
+ end
445
+ end
446
+
447
+ context 'when config.yml has no video key (older project)' do
448
+ let(:config_yml) { { 'browser' => 'chrome' } }
449
+
450
+ it 'records with the defaults' do
451
+ recorder = VideoHelper.recorder_for(driver)
452
+ recorder.start
453
+ recorder.stop(keep: true)
454
+
455
+ expect(VideoHelper.enabled?).to be(true)
456
+ expect(argv.each_cons(2).to_a).to include(['-vf', "scale='min(1280,iw)':-2"], %w[-r 5])
457
+ end
458
+ end
459
+
460
+ it 'NullRecorder answers the recorder interface with nil' do
461
+ recorder = VideoHelper::NullRecorder.new
462
+
463
+ expect([recorder.start, recorder.stop(keep: true), recorder.cleanup]).to all(be_nil)
464
+ end
465
+ end
466
+
467
+ describe '.final_attempt?' do
468
+ let(:automation) { 'selenium' }
469
+
470
+ def example_with(exception: StandardError.new('boom'), **metadata)
471
+ double('example', exception:, metadata:)
472
+ end
473
+
474
+ it 'is true when the example passed' do
475
+ expect(VideoHelper.final_attempt?(example_with(exception: nil, retry: 3, retry_attempts: 0))).to be(true)
476
+ end
477
+
478
+ it 'is false on attempt 1 of 3' do
479
+ expect(VideoHelper.final_attempt?(example_with(retry: 3, retry_attempts: 0))).to be(false)
480
+ end
481
+
482
+ it 'is false on attempt 2 of 3' do
483
+ expect(VideoHelper.final_attempt?(example_with(retry: 3, retry_attempts: 1))).to be(false)
484
+ end
485
+
486
+ it 'is true on attempt 3 of 3' do
487
+ expect(VideoHelper.final_attempt?(example_with(retry: 3, retry_attempts: 2))).to be(true)
488
+ end
489
+
490
+ it 'is true when the retry count is 0 or unset' do
491
+ expect(VideoHelper.final_attempt?(example_with(retry: 0, retry_attempts: 0))).to be(true)
492
+ expect(VideoHelper.final_attempt?(example_with)).to be(true)
493
+ end
494
+
495
+ it 'reads the default retry count of rspec-retry when the example has no retry metadata' do
496
+ RSpec.configuration.define_singleton_method(:default_retry_count) { 2 }
497
+
498
+ expect(VideoHelper.final_attempt?(example_with(retry_attempts: 0))).to be(false)
499
+ expect(VideoHelper.final_attempt?(example_with(retry_attempts: 1))).to be(true)
500
+ ensure
501
+ RSpec.configuration.singleton_class.send(:remove_method, :default_retry_count)
502
+ end
503
+
504
+ it 'lets RSPEC_RETRY_RETRY_COUNT win, as rspec-retry does' do
505
+ stub_const('ENV', ENV.to_h.merge('RSPEC_RETRY_RETRY_COUNT' => '2'))
506
+
507
+ expect(VideoHelper.final_attempt?(example_with(retry: 5, retry_attempts: 0))).to be(false)
508
+ expect(VideoHelper.final_attempt?(example_with(retry: 5, retry_attempts: 1))).to be(true)
509
+ end
510
+ end
511
+
512
+ context 'with an appium project' do
513
+ let(:automation) { 'ios' }
514
+ let(:mp4) { "\x00\x00\x00\x18ftypmp42".b }
515
+ let(:appium) { double('appium_driver', start_recording_screen: nil, stop_recording_screen: Base64.strict_encode64(mp4)) }
516
+
517
+ it 'uses an AppiumRecorder that writes the mp4 as-is when kept' do
518
+ recorder = VideoHelper.recorder_for(appium)
519
+ expect(recorder).to be_a(VideoHelper::AppiumRecorder)
520
+ recorder.start
521
+ path = recorder.stop(keep: true)
522
+
523
+ expect(path).to eq(File.join(recorder.dir, 'video.mp4'))
524
+ expect(File.binread(path)).to eq(mp4)
525
+ expect(argv).to be_nil
526
+ end
527
+
528
+ it 'stops the device recording but keeps nothing when not kept' do
529
+ recorder = VideoHelper.recorder_for(appium)
530
+ recorder.start
531
+
532
+ expect(recorder.stop(keep: false)).to be_nil
533
+ expect(appium).to have_received(:stop_recording_screen)
534
+ expect(File).not_to exist(recorder.dir)
535
+ end
536
+
537
+ it 'records through the core driver of an appium_lib Appium::Driver' do
538
+ wrapper = double('appium_lib_driver', driver: appium)
539
+ recorder = VideoHelper.recorder_for(wrapper)
540
+ recorder.start
541
+
542
+ expect(recorder.stop(keep: true)).to end_with('video.mp4')
543
+ expect(appium).to have_received(:start_recording_screen).with(time_limit: 1800)
544
+ end
545
+
546
+ it 'warns once and does not raise when start_recording_screen fails' do
547
+ allow(appium).to receive(:start_recording_screen).and_raise(StandardError, 'not supported')
548
+ recorder = VideoHelper.recorder_for(appium)
549
+
550
+ expect { recorder.start }.not_to raise_error
551
+ expect(recorder.stop(keep: true)).to be_nil
552
+ expect(warnings.size).to eq(1)
553
+ expect(File).not_to exist(recorder.dir)
554
+ end
555
+
556
+ it 'has no listener' do
557
+ expect(VideoHelper).not_to respond_to(:listener)
558
+ end
559
+
560
+ it 'asks iOS for a 1800 s recording' do
561
+ VideoHelper.recorder_for(appium).start
562
+
563
+ expect(appium).to have_received(:start_recording_screen).with(time_limit: 1800)
564
+ end
565
+
566
+ context 'when the project is android' do
567
+ let(:automation) { 'android' }
568
+
569
+ it "keeps Appium's 180 s limit, which needs no ffmpeg on the Appium host" do
570
+ VideoHelper.recorder_for(appium).start
571
+
572
+ expect(appium).to have_received(:start_recording_screen).with(time_limit: 180)
573
+ end
574
+ end
575
+
576
+ context 'when an android project sets video.time_limit in config.yml' do
577
+ let(:automation) { 'android' }
578
+ let(:video_config) { { 'enabled' => true, 'time_limit' => 600 } }
579
+
580
+ it 'uses it' do
581
+ VideoHelper.recorder_for(appium).start
582
+
583
+ expect(appium).to have_received(:start_recording_screen).with(time_limit: 600)
584
+ end
585
+ end
586
+
587
+ context 'when the project is cross-platform' do
588
+ let(:automation) { 'cross_platform' }
589
+
590
+ it 'picks the limit from the platform in config.yml' do
591
+ File.write('config/config.yml', { 'platform' => :android }.to_yaml)
592
+ VideoHelper.recorder_for(appium).start
593
+ File.write('config/config.yml', "platform: :ios\n")
594
+ VideoHelper.recorder_for(appium).start
595
+
596
+ expect(appium).to have_received(:start_recording_screen).with(time_limit: 180).ordered
597
+ expect(appium).to have_received(:start_recording_screen).with(time_limit: 1800).ordered
598
+ end
599
+ end
600
+
601
+ it 'defines final_attempt? too' do
602
+ example = double('example', exception: StandardError.new('boom'), metadata: { retry: 3, retry_attempts: 2 })
603
+
604
+ expect(VideoHelper.final_attempt?(example)).to be(true)
605
+ end
606
+ end
607
+ end
608
+ # rubocop:enable RSpec/MultipleMemoizedHelpers, RSpec/VerifiedDoubles, RSpec/ExpectOutput, RSpec/MultipleExpectations
@@ -16,7 +16,7 @@ describe 'Browser update after framework creation' do
16
16
 
17
17
  def generate_project(framework:, automation:)
18
18
  name = "browser_update_#{framework}_#{automation}"
19
- generate_framework(automation: automation, framework: framework, name: name)
19
+ generate_framework(automation:, framework:, name:)
20
20
  name
21
21
  end
22
22
 
@@ -25,17 +25,19 @@ describe 'Browser update after framework creation' do
25
25
  end
26
26
 
27
27
  shared_examples 'updates browser config after creation' do |framework, automation|
28
+ project_name = nil
29
+
28
30
  before(:all) do # rubocop:disable RSpec/BeforeAfterAll
29
- @project_name = generate_project(framework: framework, automation: automation)
31
+ project_name = generate_project(framework:, automation:)
30
32
  end
31
33
 
32
34
  after(:all) do # rubocop:disable RSpec/BeforeAfterAll
33
35
  Dir.chdir orig_dir
34
- FileUtils.rm_rf(@project_name) if @project_name
36
+ FileUtils.rm_rf(project_name) if project_name
35
37
  end
36
38
 
37
39
  before do
38
- Dir.chdir @project_name
40
+ Dir.chdir project_name
39
41
  end
40
42
 
41
43
  it 'starts with chrome as default browser' do
@@ -104,7 +106,7 @@ describe 'Browser update after framework creation' do
104
106
  UtilityCommands.new.invoke(:browser, nil, %w[:firefox --opts headless])
105
107
  UtilityCommands.new.invoke(:browser, nil, %w[--delete])
106
108
  config = YAML.load_file('config/config.yml')
107
- expect(config.dig('browser_arguments', 'firefox')).to be_nil
109
+ expect(config.dig('browser_arguments', 'firefox')).to eq([])
108
110
  end
109
111
  end
110
112
 
@@ -119,5 +121,4 @@ describe 'Browser update after framework creation' do
119
121
  context 'with watir + rspec' do
120
122
  include_examples 'updates browser config after creation', 'rspec', 'watir'
121
123
  end
122
-
123
124
  end
@@ -100,14 +100,14 @@ describe UtilityCommands do
100
100
  it 'deletes the browser arguments when passed with the delete parameter' do
101
101
  utility.new.invoke(:browser, nil, %w[:firefox --opts headless --delete])
102
102
  config = YAML.load_file('config/config.yml')
103
- expect(config.dig('browser_arguments', 'firefox')).to be_nil
103
+ expect(config.dig('browser_arguments', 'firefox')).to eq([])
104
104
  end
105
105
 
106
106
  it 'deletes the browser arguments' do
107
107
  utility.new.invoke(:browser, nil, %w[:firefox --opts headless])
108
108
  utility.new.invoke(:browser, nil, %w[--delete])
109
109
  config = YAML.load_file('config/config.yml')
110
- expect(config.dig('browser_arguments', 'firefox')).to be_nil
110
+ expect(config.dig('browser_arguments', 'firefox')).to eq([])
111
111
  end
112
112
  end
113
113
  end