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
@@ -104,5 +104,4 @@ RSpec.describe 'Scaffolding features' do
104
104
  expect(Pathname.new('spec/dashboard_page_spec.rb')).to be_file
105
105
  end
106
106
  end
107
-
108
107
  end
@@ -35,5 +35,4 @@ describe 'Selenium based frameworks' do
35
35
  context 'with cucumber' do
36
36
  include_examples 'runs tests successfully', 'cucumber'
37
37
  end
38
-
39
38
  end
@@ -35,5 +35,4 @@ describe 'Watir based frameworks' do
35
35
  context 'with cucumber' do
36
36
  include_examples 'runs tests successfully', 'cucumber'
37
37
  end
38
-
39
38
  end
@@ -0,0 +1,424 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require 'digest'
5
+ require 'fileutils'
6
+ require 'stringio'
7
+ require 'ruby_raider/ffmpeg'
8
+
9
+ # rubocop:disable RSpec/FilePath, RSpec/MultipleMemoizedHelpers
10
+ describe RubyRaider::Ffmpeg do
11
+ let(:bytes) { "#!/bin/sh\n" }
12
+ let(:digest) { Digest::SHA256.hexdigest(bytes) }
13
+ let(:cache) { Dir.mktmpdir('raider-ffmpeg-cache') }
14
+ let(:env) { { 'RAIDER_CACHE_DIR' => cache } }
15
+ let(:platform) { described_class.platform }
16
+ let(:manifest) do
17
+ { version: '9.9',
18
+ assets: described_class::MANIFEST[:assets].keys.to_h { |p| [p, { url: "https://example.test/#{p}", sha256: digest }] } }
19
+ end
20
+ let(:calls) { [] }
21
+ let(:warnings) { [] }
22
+ let(:warner) { ->(msg) { warnings << msg } }
23
+ let(:fetch) do
24
+ lambda do |url, dest|
25
+ calls << url
26
+ File.binwrite(dest, bytes)
27
+ end
28
+ end
29
+ let(:binary_name) { platform.to_s.start_with?('windows') ? 'ffmpeg.exe' : 'ffmpeg' }
30
+ let(:cached_path) { File.join(cache, 'ffmpeg', '9.9', binary_name) }
31
+
32
+ before { described_class.send(:reset_verified_cache!) }
33
+
34
+ after { FileUtils.rm_rf(cache) }
35
+
36
+ it 'maps host os/cpu to the four platforms and nil otherwise' do
37
+ expect(described_class.platform(host_os: 'darwin23', host_cpu: 'arm64')).to eq('darwin-arm64')
38
+ expect(described_class.platform(host_os: 'darwin23', host_cpu: 'x86_64')).to eq('darwin-x64')
39
+ expect(described_class.platform(host_os: 'linux-gnu', host_cpu: 'x86_64')).to eq('linux-x64')
40
+ expect(described_class.platform(host_os: 'mingw32', host_cpu: 'x64')).to eq('windows-x64')
41
+ expect(described_class.platform(host_os: 'linux-gnu', host_cpu: 'aarch64')).to be_nil
42
+ end
43
+
44
+ it 'ships a placeholder manifest with https urls and nil checksums for all four platforms' do
45
+ assets = described_class::MANIFEST[:assets]
46
+ expect(assets.keys).to contain_exactly('darwin-arm64', 'darwin-x64', 'linux-x64', 'windows-x64')
47
+ expect(assets.values.map { |a| a[:url] }).to all(start_with('https://'))
48
+ expect(assets.values.map { |a| a[:sha256] }).to all(be_nil)
49
+ end
50
+
51
+ it 'derives the cache dir from RAIDER_CACHE_DIR or the home cache', :aggregate_failures do
52
+ expect(described_class.cache_dir(env:)).to eq(cache)
53
+ expect(described_class.cache_dir(env: {}, platform: 'linux-x64')).to eq(File.join(Dir.home, '.cache', 'raider'))
54
+ end
55
+
56
+ it 'uses LOCALAPPDATA on windows', :aggregate_failures do
57
+ local = { 'LOCALAPPDATA' => '/Users/x/AppData/Local' }
58
+ expect(described_class.cache_dir(env: local, platform: 'windows-x64')).to eq('/Users/x/AppData/Local/raider')
59
+ expect(described_class.cached_path(env: local, manifest: described_class::MANIFEST, platform: 'windows-x64'))
60
+ .to eq('/Users/x/AppData/Local/raider/ffmpeg/7.1/ffmpeg.exe')
61
+ end
62
+
63
+ it 'deep-freezes the manifest', :aggregate_failures do
64
+ expect(described_class::MANIFEST).to be_frozen
65
+ expect(described_class::MANIFEST[:assets]).to be_frozen
66
+ expect(described_class::MANIFEST[:assets].values).to all(be_frozen)
67
+ expect(described_class::MANIFEST[:assets].values.map { |a| a[:url] }).to all(be_frozen)
68
+ expect(described_class::MANIFEST[:version]).to be_frozen
69
+ end
70
+
71
+ it 'prefers RAIDER_FFMPEG_PATH and never downloads then' do
72
+ override = File.join(cache, 'my-ffmpeg')
73
+ File.write(override, bytes)
74
+ File.chmod(0o755, override)
75
+ result = described_class.ensure!(env: env.merge('RAIDER_FFMPEG_PATH' => override), manifest:,
76
+ fetch:, warn: warner)
77
+ expect(result).to eq(override)
78
+ expect(calls).to be_empty
79
+ expect(warnings).to be_empty
80
+ end
81
+
82
+ it 'ignores ffmpeg on PATH when no override is set' do
83
+ bin = Dir.mktmpdir('raider-path')
84
+ File.write(File.join(bin, 'ffmpeg'), bytes)
85
+ File.chmod(0o755, File.join(bin, 'ffmpeg'))
86
+ expect(described_class.resolve(env: env.merge('PATH' => bin), manifest:)).to be_nil
87
+ ensure
88
+ FileUtils.rm_rf(bin)
89
+ end
90
+
91
+ it 'downloads once, verifies, caches at <cache>/ffmpeg/<version>/ffmpeg, chmods +x', :aggregate_failures do
92
+ result = described_class.ensure!(env:, manifest:, fetch:, warn: warner)
93
+ expect(result).to eq(cached_path)
94
+ expect(File.binread(result)).to eq(bytes)
95
+ expect(File.executable?(result)).to be(true)
96
+ expect(calls.size).to eq(1)
97
+ expect(warnings).to be_empty
98
+ expect(Dir.children(File.dirname(result))).to eq([binary_name])
99
+ end
100
+
101
+ it 'makes no fetch call on a second ensure!' do
102
+ described_class.ensure!(env:, manifest:, fetch:, warn: warner)
103
+ again = described_class.ensure!(env:, manifest:, fetch:, warn: warner)
104
+ expect(again).to eq(cached_path)
105
+ expect(calls.size).to eq(1)
106
+ end
107
+
108
+ it 'deletes the file and returns nil with one warning on a checksum mismatch' do
109
+ bad = manifest.merge(assets: manifest[:assets].transform_values { |a| a.merge(sha256: 'a' * 64) })
110
+ result = described_class.ensure!(env:, manifest: bad, fetch:, warn: warner)
111
+ expect(result).to be_nil
112
+ expect(warnings.size).to eq(1)
113
+ expect(warnings.first).to match(/checksum/i)
114
+ expect(Dir.glob(File.join(cache, 'ffmpeg', '**', '*')).select { |f| File.file?(f) }).to be_empty
115
+ end
116
+
117
+ it 'returns nil with "ffmpeg build not published yet" when the manifest sha256 is nil' do
118
+ unpublished = manifest.merge(assets: manifest[:assets].transform_values { |a| a.merge(sha256: nil) })
119
+ result = described_class.ensure!(env:, manifest: unpublished, fetch:, warn: warner)
120
+ expect(result).to be_nil
121
+ expect(warnings).to eq(['ffmpeg build not published yet'])
122
+ expect(calls).to be_empty
123
+ end
124
+
125
+ it 'returns nil with one warning when fetch raises' do
126
+ boom = ->(_url, _dest) { raise IOError, 'network down' }
127
+ result = described_class.ensure!(env:, manifest:, fetch: boom, warn: warner)
128
+ expect(result).to be_nil
129
+ expect(warnings.size).to eq(1)
130
+ expect(warnings.first).to include('network down')
131
+ end
132
+
133
+ it 'rejects non-https manifest urls without fetching' do
134
+ http = manifest.merge(assets: manifest[:assets].transform_values { |a| a.merge(url: 'http://example.test/x') })
135
+ result = described_class.ensure!(env:, manifest: http, fetch:, warn: warner)
136
+ expect(result).to be_nil
137
+ expect(warnings.size).to eq(1)
138
+ expect(calls).to be_empty
139
+ end
140
+
141
+ it 'leaves exactly one valid binary when 4 threads call ensure! concurrently' do
142
+ results = Array.new(4) do
143
+ Thread.new { described_class.ensure!(env:, manifest:, fetch:, warn: warner) }
144
+ end.map(&:value)
145
+ expect(results).to all(eq(cached_path))
146
+ expect(Dir.children(File.dirname(cached_path))).to eq([binary_name])
147
+ expect(Digest::SHA256.file(cached_path).hexdigest).to eq(digest)
148
+ expect(warnings).to be_empty
149
+ end
150
+
151
+ it 'warns once and falls through when RAIDER_FFMPEG_PATH is not an executable file' do
152
+ bad = env.merge('RAIDER_FFMPEG_PATH' => File.join(cache, 'missing'))
153
+ expect(described_class.resolve(env: bad, manifest:, warn: warner)).to be_nil
154
+ expect(warnings).to eq(['RAIDER_FFMPEG_PATH is not an executable file; ignoring'])
155
+ end
156
+
157
+ describe 'cached binary verification' do
158
+ before { FileUtils.mkdir_p(File.dirname(cached_path)) }
159
+
160
+ it 'rejects a tampered cache: nil, one warning, no fetch, file removed', :aggregate_failures do
161
+ File.write(cached_path, 'tampered')
162
+ File.chmod(0o755, cached_path)
163
+ result = described_class.ensure!(env:, manifest:, fetch:, warn: warner)
164
+ expect(result).to be_nil
165
+ expect(warnings).to eq(['cached ffmpeg failed checksum; video disabled'])
166
+ expect(calls).to be_empty
167
+ expect(File.exist?(cached_path)).to be(false)
168
+ end
169
+
170
+ it 'never trusts a cached file when the manifest sha256 is nil', :aggregate_failures do
171
+ File.binwrite(cached_path, bytes)
172
+ File.chmod(0o755, cached_path)
173
+ unpublished = manifest.merge(assets: manifest[:assets].transform_values { |a| a.merge(sha256: nil) })
174
+ result = described_class.ensure!(env:, manifest: unpublished, fetch:, warn: warner)
175
+ expect(result).to be_nil
176
+ expect(warnings).to eq(['ffmpeg build not published yet'])
177
+ expect(calls).to be_empty
178
+ end
179
+
180
+ it 'does not rehash after a successful verification' do
181
+ File.binwrite(cached_path, bytes)
182
+ File.chmod(0o755, cached_path)
183
+ described_class.ensure!(env:, manifest:, fetch:, warn: warner)
184
+ allow(Digest::SHA256).to receive(:file).and_call_original
185
+ described_class.ensure!(env:, manifest:, fetch:, warn: warner)
186
+ expect(Digest::SHA256).not_to have_received(:file)
187
+ end
188
+ end
189
+
190
+ describe 'a failed download leaves no empty dirs for CI to cache' do
191
+ let(:boom) { ->(_url, _dest) { raise IOError, 'network down' } }
192
+
193
+ it 'removes the version dir (and the ffmpeg dir) it created' do
194
+ described_class.ensure!(env:, manifest:, fetch: boom, warn: warner)
195
+ expect(File).not_to exist(File.join(cache, 'ffmpeg'))
196
+ expect(File).to exist(cache)
197
+ end
198
+
199
+ it 'removes them after a checksum mismatch too' do
200
+ bad = ->(_url, dest) { File.binwrite(dest, 'tampered') }
201
+ described_class.ensure!(env:, manifest:, fetch: bad, warn: warner)
202
+ expect(File).not_to exist(File.join(cache, 'ffmpeg'))
203
+ end
204
+
205
+ it 'keeps a version dir that already existed or is not empty' do
206
+ FileUtils.mkdir_p(File.dirname(cached_path))
207
+ File.write(File.join(File.dirname(cached_path), 'other'), 'x')
208
+ described_class.ensure!(env:, manifest:, fetch: boom, warn: warner)
209
+ expect(File).to exist(File.join(File.dirname(cached_path), 'other'))
210
+ end
211
+ end
212
+
213
+ describe 'never raising' do
214
+ it 'warns once and returns nil when the cache dir cannot be created', :aggregate_failures do
215
+ blocker = File.join(cache, 'blocker')
216
+ File.write(blocker, 'x')
217
+ result = described_class.ensure!(env: { 'RAIDER_CACHE_DIR' => blocker }, manifest:, fetch:, warn: warner)
218
+ expect(result).to be_nil
219
+ expect(warnings.size).to eq(1)
220
+ expect(warnings.first).to match(/download failed/)
221
+ end
222
+
223
+ it 'warns once and returns nil when the home dir cannot be resolved', :aggregate_failures do
224
+ allow(Dir).to receive(:home).and_raise(ArgumentError, 'couldn\'t find HOME')
225
+ expect(described_class.ensure!(env: {}, manifest:, fetch:, warn: warner)).to be_nil
226
+ expect(warnings.size).to eq(1)
227
+ expect(warnings.first).to include("couldn't find HOME")
228
+ end
229
+ end
230
+
231
+ it 'resolve never raises either', :aggregate_failures do
232
+ allow(Dir).to receive(:home).and_raise(ArgumentError, "couldn't find HOME")
233
+ expect(described_class.resolve(env: {}, manifest:, warn: warner, platform: 'linux-x64')).to be_nil
234
+ expect(warnings.size).to eq(1)
235
+ expect(warnings.first).to include("couldn't find HOME")
236
+ end
237
+
238
+ it 'reports the injected platform when no build exists for it' do
239
+ described_class.ensure!(env:, manifest: { version: '9.9', assets: {} }, fetch:, warn: warner, platform: 'linux-x64')
240
+ expect(warnings).to eq(['no ffmpeg build for this platform (linux-x64)'])
241
+ end
242
+
243
+ it 'rehashes a same-size replacement even when mtime is preserved' do
244
+ FileUtils.mkdir_p(File.dirname(cached_path))
245
+ File.binwrite(cached_path, bytes)
246
+ File.chmod(0o755, cached_path)
247
+ described_class.ensure!(env:, manifest:, fetch:, warn: warner)
248
+ mtime = File.mtime(cached_path)
249
+ File.delete(cached_path)
250
+ File.binwrite(cached_path, 'X' * bytes.size)
251
+ File.chmod(0o755, cached_path)
252
+ File.utime(mtime, mtime, cached_path)
253
+ expect(described_class.ensure!(env:, manifest:, fetch:, warn: warner)).to be_nil
254
+ expect(warnings).to eq(['cached ffmpeg failed checksum; video disabled'])
255
+ end
256
+
257
+ describe 'DEFAULT_WARN' do
258
+ it 'writes to $stderr even when $VERBOSE is nil' do
259
+ original = $VERBOSE
260
+ $VERBOSE = nil
261
+ expect { described_class::DEFAULT_WARN.call('x') }.to output("[video] x\n").to_stderr
262
+ ensure
263
+ $VERBOSE = original
264
+ end
265
+ end
266
+
267
+ describe '.obtainable?' do
268
+ let(:unpublished) { manifest.merge(assets: manifest[:assets].transform_values { |a| a.merge(sha256: nil) }) }
269
+ let(:override) do
270
+ File.join(cache, 'my-ffmpeg').tap do |path|
271
+ File.write(path, bytes)
272
+ File.chmod(0o755, path)
273
+ end
274
+ end
275
+
276
+ it 'is true when RAIDER_FFMPEG_PATH is an executable file, even with an unpublished manifest' do
277
+ expect(described_class.obtainable?(env: { 'RAIDER_FFMPEG_PATH' => override }, manifest: unpublished)).to be(true)
278
+ end
279
+
280
+ it 'is true when the platform has a checksummed asset' do
281
+ expect(described_class.obtainable?(env: {}, manifest:, platform: 'linux-x64')).to be(true)
282
+ end
283
+
284
+ it 'is false with no override and a nil checksum (the placeholder manifest)' do
285
+ expect(described_class.obtainable?(env: {}, platform: 'darwin-arm64')).to be(false)
286
+ end
287
+
288
+ it 'is false for an unknown platform or a non-executable override' do
289
+ File.chmod(0o644, override)
290
+ expect(described_class.obtainable?(env: {}, manifest:, platform: 'plan9-mips')).to be(false)
291
+ expect(described_class.obtainable?(env: { 'RAIDER_FFMPEG_PATH' => override }, manifest: unpublished)).to be(false)
292
+ end
293
+
294
+ it 'never fetches, hashes or warns' do
295
+ allow(Digest::SHA256).to receive(:file)
296
+ allow(Net::HTTP).to receive(:start)
297
+ expect { described_class.obtainable?(env:, manifest:) }.not_to output.to_stderr
298
+ expect(Digest::SHA256).not_to have_received(:file)
299
+ expect(Net::HTTP).not_to have_received(:start)
300
+ end
301
+
302
+ it 'returns false instead of raising on a broken manifest' do
303
+ expect(described_class.obtainable?(env: {}, manifest: { version: '1' }, platform: 'linux-x64')).to be(false)
304
+ end
305
+ end
306
+
307
+ describe '.http_get' do
308
+ let(:dest) { File.join(cache, 'download') }
309
+ let(:http) { instance_double(Net::HTTP) }
310
+
311
+ def response(klass, code, location: nil, body: nil)
312
+ klass.new('1.1', code, 'x').tap do |res|
313
+ res['location'] = location if location
314
+ allow(res).to receive(:read_body).and_yield(body) if body
315
+ end
316
+ end
317
+
318
+ def stub_responses(*responses)
319
+ allow(Net::HTTP).to receive(:start).and_yield(http)
320
+ allow(http).to receive(:request) { |_req, &blk| blk.call(responses.shift) }
321
+ end
322
+
323
+ it 'follows a redirect to https and writes the body', :aggregate_failures do
324
+ stub_responses(response(Net::HTTPFound, '302', location: 'https://cdn.example.test/f'),
325
+ response(Net::HTTPOK, '200', body: 'payload'))
326
+ described_class.http_get('https://example.test/f', dest)
327
+ expect(File.read(dest)).to eq('payload')
328
+ expect(Net::HTTP).to have_received(:start).with('cdn.example.test', 443, hash_including(use_ssl: true))
329
+ end
330
+
331
+ it 'raises on a redirect to http' do
332
+ stub_responses(response(Net::HTTPFound, '302', location: 'http://evil.example.test/f'))
333
+ expect { described_class.http_get('https://example.test/f', dest) }.to raise_error(ArgumentError, /non-https/)
334
+ end
335
+
336
+ it 'raises after more than 5 redirects' do
337
+ stub_responses(*Array.new(7) { response(Net::HTTPFound, '302', location: 'https://example.test/f') })
338
+ expect { described_class.http_get('https://example.test/f', dest) }.to raise_error(IOError, /too many redirects/)
339
+ end
340
+
341
+ it 'raises on a non-2xx response' do
342
+ stub_responses(response(Net::HTTPNotFound, '404'))
343
+ expect { described_class.http_get('https://example.test/f', dest) }.to raise_error(IOError, /404/)
344
+ end
345
+
346
+ it 'raises on a redirect without Location' do
347
+ stub_responses(response(Net::HTTPFound, '302'))
348
+ expect { described_class.http_get('https://example.test/f', dest) }.to raise_error(IOError, /Location/)
349
+ end
350
+
351
+ context 'with an overall deadline' do
352
+ def slow_ok(chunks:, pause:)
353
+ Net::HTTPOK.new('1.1', '200', 'x').tap do |res|
354
+ allow(res).to receive(:read_body) do |&blk|
355
+ chunks.times do
356
+ sleep pause
357
+ blk.call('x')
358
+ end
359
+ end
360
+ end
361
+ end
362
+
363
+ it 'defaults to 300 seconds' do
364
+ expect(described_class::DOWNLOAD_TIMEOUT).to eq(300)
365
+ end
366
+
367
+ it 'aborts a body that trickles past the deadline' do
368
+ stub_responses(slow_ok(chunks: 50, pause: 0.02))
369
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
370
+ expect { described_class.http_get('https://example.test/f', dest, timeout: 0.2) }
371
+ .to raise_error(IOError, /longer than 0.2 seconds/)
372
+ expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 0.6
373
+ end
374
+
375
+ it 'caps the socket timeouts at the time left' do
376
+ stub_responses(response(Net::HTTPOK, '200', body: 'x'))
377
+ described_class.http_get('https://example.test/f', dest, timeout: 5)
378
+ expect(Net::HTTP).to have_received(:start)
379
+ .with('example.test', 443, hash_including(open_timeout: be <= 5, read_timeout: be <= 5))
380
+ end
381
+
382
+ it 'shares one deadline across redirects' do
383
+ stub_responses(response(Net::HTTPFound, '302', location: 'https://example.test/g'),
384
+ slow_ok(chunks: 50, pause: 0.02))
385
+ expect { described_class.http_get('https://example.test/f', dest, timeout: 0.2) }
386
+ .to raise_error(IOError, /longer than 0.2 seconds/)
387
+ end
388
+
389
+ it 'makes ensure! warn once, delete the .part and return nil' do
390
+ stub_responses(slow_ok(chunks: 50, pause: 0.02))
391
+ fetch = ->(url, part) { described_class.http_get(url, part, timeout: 0.2) }
392
+ result = described_class.ensure!(env:, manifest:, fetch:, warn: warner)
393
+ expect(result).to be_nil
394
+ expect(warnings).to eq(['ffmpeg download failed: download took longer than 0.2 seconds'])
395
+ expect(Dir.glob(File.join(cache, '**', '*.part'))).to be_empty
396
+ end
397
+ end
398
+
399
+ it 'does not nest connections when following redirects' do
400
+ depth = 0
401
+ max = 0
402
+ allow(Net::HTTP).to receive(:start) do |*_args, &blk|
403
+ depth += 1
404
+ max = [max, depth].max
405
+ blk.call(http)
406
+ ensure
407
+ depth -= 1
408
+ end
409
+ queue = [response(Net::HTTPFound, '302', location: 'https://example.test/g'),
410
+ response(Net::HTTPOK, '200', body: 'x')]
411
+ allow(http).to receive(:request) { |_req, &blk| blk.call(queue.shift) }
412
+ described_class.http_get('https://example.test/f', dest)
413
+ expect(max).to eq(1)
414
+ end
415
+ end
416
+
417
+ it 'loads standalone without Thor' do
418
+ root = File.expand_path('../..', __dir__)
419
+ ok = system(RbConfig.ruby, "-I#{File.join(root, 'lib')}", '-e',
420
+ 'require "ruby_raider/ffmpeg"; exit(defined?(Thor) ? 1 : 0)')
421
+ expect(ok).to be(true)
422
+ end
423
+ end
424
+ # rubocop:enable RSpec/FilePath, RSpec/MultipleMemoizedHelpers
@@ -57,11 +57,28 @@ RSpec.describe Utilities do
57
57
  end
58
58
  end
59
59
 
60
+ describe '.browser=' do
61
+ it 'defaults browser_arguments to [] for a browser without an entry' do
62
+ %w[safari edge].each do |name|
63
+ described_class.browser = name
64
+ config = YAML.load_file(config_path)
65
+ expect(config['browser']).to eq(name)
66
+ expect(config['browser_arguments'][name]).to eq([])
67
+ end
68
+ end
69
+
70
+ it 'keeps existing browser_arguments when switching' do
71
+ described_class.browser = 'firefox'
72
+ config = YAML.load_file(config_path)
73
+ expect(config['browser_arguments']['firefox']).to eq(%w[acceptInsecureCerts])
74
+ end
75
+ end
76
+
60
77
  describe '.delete_browser_options' do
61
- it 'removes browser_arguments for the current browser' do
78
+ it 'clears browser_arguments for the current browser to an empty list, not nil' do
62
79
  described_class.delete_browser_options
63
80
  config = YAML.load_file(config_path)
64
- expect(config['browser_arguments']['chrome']).to be_nil
81
+ expect(config['browser_arguments']['chrome']).to eq([])
65
82
  end
66
83
 
67
84
  it 'preserves other browser arguments' do
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require 'fileutils'
5
+ require 'yaml'
6
+ require 'stringio'
7
+ require_relative '../../lib/commands/utility_commands'
8
+
9
+ # rubocop:disable RSpec/FilePath
10
+ RSpec.describe UtilityCommands do
11
+ let(:cache) { Dir.mktmpdir('raider-video-cache') }
12
+ let(:project) { Dir.mktmpdir('raider-video-project') }
13
+ let(:config_path) { File.join(project, 'config', 'config.yml') }
14
+
15
+ around do |example|
16
+ saved = ENV.to_h.slice('RAIDER_CACHE_DIR', 'RAIDER_FFMPEG_PATH')
17
+ ENV['RAIDER_CACHE_DIR'] = cache
18
+ ENV.delete('RAIDER_FFMPEG_PATH')
19
+ FileUtils.mkdir_p(File.join(project, 'config'))
20
+ File.write(config_path, "browser: chrome\ntimeout: 10\n")
21
+ Dir.chdir(project) { example.run }
22
+ ensure
23
+ saved.each { |k, v| ENV[k] = v }
24
+ ENV.delete('RAIDER_CACHE_DIR') unless saved.key?('RAIDER_CACHE_DIR')
25
+ FileUtils.rm_rf([cache, project])
26
+ end
27
+
28
+ def run_cli(*args)
29
+ out = StringIO.new
30
+ err = StringIO.new
31
+ status = 0
32
+ $stdout = out
33
+ $stderr = err
34
+ begin
35
+ described_class.start(args)
36
+ rescue SystemExit => e
37
+ status = e.status
38
+ ensure
39
+ $stdout = STDOUT
40
+ $stderr = STDERR
41
+ end
42
+ [out.string, err.string, status]
43
+ end
44
+
45
+ describe 'video install' do
46
+ it 'exits 1 and explains when the build is not published yet' do
47
+ out, err, status = run_cli('video', 'install')
48
+ expect(status).to eq(1)
49
+ expect(err).to include('[video]', 'ffmpeg build not published yet')
50
+ expect(out).to eq('')
51
+ end
52
+
53
+ it 'prints the path and exits 0 when ensure! succeeds' do
54
+ require 'ruby_raider/ffmpeg'
55
+ allow(RubyRaider::Ffmpeg).to receive(:ensure!).and_return('/tmp/ffmpeg')
56
+ out, _err, status = run_cli('video', 'install')
57
+ expect(status).to eq(0)
58
+ expect(out).to include('/tmp/ffmpeg')
59
+ end
60
+ end
61
+
62
+ describe 'video status' do
63
+ it 'reports not installed with an empty cache' do
64
+ out, _err, status = run_cli('video', 'status')
65
+ expect(status).to eq(0)
66
+ expect(out.strip).to eq('ffmpeg 7.1: not installed')
67
+ end
68
+
69
+ it 'labels a RAIDER_FFMPEG_PATH binary by its source, not the manifest version' do
70
+ script = File.join(cache, 'fake-ffmpeg')
71
+ File.write(script, "#!/bin/sh\n")
72
+ File.chmod(0o755, script)
73
+ ENV['RAIDER_FFMPEG_PATH'] = script
74
+ out, = run_cli('video', 'status')
75
+ expect(out.strip).to eq("ffmpeg (RAIDER_FFMPEG_PATH): #{script}")
76
+ end
77
+
78
+ it 'prints the cached binary with the manifest version without downloading' do
79
+ require 'ruby_raider/ffmpeg'
80
+ allow(RubyRaider::Ffmpeg).to receive(:resolve).and_return('/cache/ffmpeg/7.1/ffmpeg')
81
+ out, = run_cli('video', 'status')
82
+ expect(out.strip).to eq('ffmpeg 7.1: /cache/ffmpeg/7.1/ffmpeg')
83
+ end
84
+ end
85
+
86
+ describe 'video on/off' do
87
+ it 'round-trips video.enabled, keeping other keys' do
88
+ run_cli('video', 'off')
89
+ config = YAML.load_file(config_path)
90
+ expect(config['video']).to eq('enabled' => false)
91
+ expect(config).to include('browser' => 'chrome', 'timeout' => 10)
92
+
93
+ run_cli('video', 'on')
94
+ expect(YAML.load_file(config_path)['video']).to eq('enabled' => true)
95
+ end
96
+
97
+ it 'preserves other keys under video' do
98
+ File.write(config_path, "video:\n enabled: true\n fps: 5\n")
99
+ run_cli('video', 'off')
100
+ expect(YAML.load_file(config_path)['video']).to eq('enabled' => false, 'fps' => 5)
101
+ end
102
+ end
103
+
104
+ describe 'video with an unknown subcommand' do
105
+ it 'exits 1 with usage' do
106
+ _out, err, status = run_cli('video', 'bogus')
107
+ expect(status).to eq(1)
108
+ expect(err).to include('install|status|on|off')
109
+ end
110
+ end
111
+
112
+ describe 'help timeout' do
113
+ it 'does not list the stray config options' do
114
+ out, = run_cli('help', 'timeout')
115
+ expect(out).not_to include('--path')
116
+ expect(out).not_to include('--delete')
117
+ end
118
+ end
119
+ end
120
+ # rubocop:enable RSpec/FilePath