ruby_raider 3.8.0 → 3.10.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 (81) 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 +22 -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/partials/appium_env.tt +6 -0
  15. data/lib/generators/cucumber/templates/partials/selenium_env.tt +6 -0
  16. data/lib/generators/cucumber/templates/partials/watir_env.tt +6 -0
  17. data/lib/generators/helper_generator.rb +5 -1
  18. data/lib/generators/infrastructure/azure_generator.rb +1 -0
  19. data/lib/generators/infrastructure/github_generator.rb +1 -0
  20. data/lib/generators/infrastructure/templates/azure_pipelines.tt +26 -2
  21. data/lib/generators/infrastructure/templates/github.tt +31 -1
  22. data/lib/generators/infrastructure/templates/github_appium.tt +50 -1
  23. data/lib/generators/invoke_generators.rb +20 -2
  24. data/lib/generators/rspec/rspec_generator.rb +0 -1
  25. data/lib/generators/templates/common/gemfile.tt +1 -1
  26. data/lib/generators/templates/common/git_ignore.tt +3 -0
  27. data/lib/generators/templates/common/partials/mobile_config.tt +5 -0
  28. data/lib/generators/templates/common/partials/web_config.tt +6 -1
  29. data/lib/generators/templates/common/read_me.tt +80 -2
  30. data/lib/generators/templates/helpers/browser_helper.tt +5 -1
  31. data/lib/generators/templates/helpers/driver_helper.tt +1 -0
  32. data/lib/generators/templates/helpers/partials/selenium_driver.tt +4 -1
  33. data/lib/generators/templates/helpers/spec_helper.tt +8 -0
  34. data/lib/generators/templates/helpers/video_helper.tt +338 -171
  35. data/lib/ruby_raider/ffmpeg.rb +280 -0
  36. data/lib/ruby_raider.rb +2 -3
  37. data/lib/scaffolding/project_detector.rb +1 -3
  38. data/lib/scaffolding/scaffolding.rb +0 -1
  39. data/lib/utilities/utilities.rb +15 -2
  40. data/lib/version +1 -1
  41. data/ruby_raider.gemspec +3 -1
  42. data/sig/commands/utility_commands.rbs +4 -1
  43. data/sig/generators/invoke_generators.rbs +2 -3
  44. data/sig/ruby_raider/ffmpeg.rbs +23 -0
  45. data/sig/utilities/utilities.rbs +1 -0
  46. data/spec/commands/raider_commands_spec.rb +56 -1
  47. data/spec/generators/fixtures/video_helper_stubs/selenium-webdriver.rb +28 -0
  48. data/spec/generators/video_helper_spec.rb +608 -0
  49. data/spec/integration/commands/browser_update_after_creation_spec.rb +7 -6
  50. data/spec/integration/commands/utility_commands_spec.rb +2 -2
  51. data/spec/integration/content/azure_content_spec.rb +48 -3
  52. data/spec/integration/content/ci_content_spec.rb +115 -5
  53. data/spec/integration/content/common_content_spec.rb +8 -0
  54. data/spec/integration/content/config_content_spec.rb +14 -0
  55. data/spec/integration/content/cucumber_reporter_content_spec.rb +22 -1
  56. data/spec/integration/content/gemfile_content_spec.rb +2 -3
  57. data/spec/integration/content/helper_content_spec.rb +146 -0
  58. data/spec/integration/content/page_content_spec.rb +0 -1
  59. data/spec/integration/content/readme_content_spec.rb +65 -0
  60. data/spec/integration/content/rspec_reporter_content_spec.rb +2 -2
  61. data/spec/integration/content/test_content_spec.rb +2 -3
  62. data/spec/integration/end_to_end_features_spec.rb +0 -2
  63. data/spec/integration/end_to_end_spec.rb +252 -26
  64. data/spec/integration/generators/azure_generator_spec.rb +7 -6
  65. data/spec/integration/generators/common_generator_spec.rb +1 -1
  66. data/spec/integration/generators/invoke_generators_spec.rb +52 -0
  67. data/spec/integration/scaffolding_e2e_spec.rb +13 -24
  68. data/spec/scaffolding/scaffold_project_detector_spec.rb +0 -1
  69. data/spec/scaffolding/scaffolding_features_spec.rb +0 -1
  70. data/spec/system/selenium_spec.rb +0 -1
  71. data/spec/system/watir_spec.rb +0 -1
  72. data/spec/utilities/ffmpeg_spec.rb +424 -0
  73. data/spec/utilities/utilities_spec.rb +19 -2
  74. data/spec/utilities/video_command_spec.rb +120 -0
  75. metadata +49 -11
  76. data/lib/commands/adopt_commands.rb +0 -42
  77. data/lib/commands/loaded_commands.rb +0 -9
  78. data/lib/commands/plugin_commands.rb +0 -32
  79. data/sig/commands/adopt_commands.rbs +0 -8
  80. data/sig/commands/loaded_commands.rbs +0 -5
  81. data/sig/commands/plugin_commands.rbs +0 -9
@@ -23,11 +23,7 @@ end
23
23
 
24
24
  # End-to-end scaffolding tests that verify the scaffold command creates the right
25
25
  # files with the right content across all automation × framework combinations.
26
- describe 'Scaffolding E2E' do # rubocop:disable RSpec/DescribeClass
27
-
28
- WEB_AUTOMATIONS = %w[selenium watir].freeze
29
- TEST_FRAMEWORKS = %w[rspec cucumber].freeze
30
-
26
+ describe 'Scaffolding E2E' do
31
27
  def self.scaffold_style(framework)
32
28
  case framework
33
29
  when 'rspec' then :spec
@@ -36,7 +32,7 @@ describe 'Scaffolding E2E' do # rubocop:disable RSpec/DescribeClass
36
32
  end
37
33
 
38
34
  # Build a minimal mock project that the scaffolding system can detect.
39
- def self.build_mock_project(automation, framework) # rubocop:disable Metrics/MethodLength
35
+ def self.build_mock_project(automation, framework)
40
36
  dir = File.expand_path("tmp_scaffold_e2e_#{framework}_#{automation}")
41
37
  FileUtils.rm_rf(dir)
42
38
  FileUtils.mkdir_p("#{dir}/page_objects/pages")
@@ -61,7 +57,7 @@ describe 'Scaffolding E2E' do # rubocop:disable RSpec/DescribeClass
61
57
  when 'rspec' then gems << "gem 'rspec'"
62
58
  when 'cucumber' then gems << "gem 'cucumber'"
63
59
  end
64
- File.write("#{dir}/Gemfile", gems.join("\n") + "\n")
60
+ File.write("#{dir}/Gemfile", "#{gems.join("\n")}\n")
65
61
  File.write("#{dir}/config/config.yml", "browser: chrome\nurl: http://localhost:3000\n")
66
62
  File.write("#{dir}/page_objects/abstract/page.rb", "class Page; end\n")
67
63
  File.write("#{dir}/page_objects/abstract/component.rb", "class Component; end\n")
@@ -91,7 +87,7 @@ describe 'Scaffolding E2E' do # rubocop:disable RSpec/DescribeClass
91
87
  end
92
88
 
93
89
  it 'includes url method (config has url)' do
94
- expect(page_content).to include("def url(_page)")
90
+ expect(page_content).to include('def url(_page)')
95
91
  expect(page_content).to include("'checkout'")
96
92
  end
97
93
  end
@@ -326,11 +322,11 @@ describe 'Scaffolding E2E' do # rubocop:disable RSpec/DescribeClass
326
322
  # Test matrix: automation × framework
327
323
  # ══════════════════════════════════════════════
328
324
 
329
- WEB_AUTOMATIONS.each do |automation|
330
- TEST_FRAMEWORKS.each do |framework|
325
+ %w[selenium watir].each do |automation|
326
+ %w[rspec cucumber].each do |framework|
331
327
  style = scaffold_style(framework)
332
328
 
333
- context "with #{framework} and #{automation}" do # rubocop:disable RSpec/ContextWording
329
+ context "with #{framework} and #{automation}" do
334
330
  let(:scaffold) { ScaffoldingCommands }
335
331
 
336
332
  # Use absolute path so Dir.chdir is safe regardless of current CWD
@@ -367,6 +363,9 @@ describe 'Scaffolding E2E' do # rubocop:disable RSpec/DescribeClass
367
363
  end
368
364
  end
369
365
 
366
+ # Page content validation
367
+ let(:page_content) { File.read('page_objects/pages/checkout.rb') }
368
+
370
369
  it 'creates page object' do
371
370
  expect(Pathname.new('page_objects/pages/checkout.rb')).to be_file
372
371
  end
@@ -389,9 +388,6 @@ describe 'Scaffolding E2E' do # rubocop:disable RSpec/DescribeClass
389
388
  end
390
389
  end
391
390
 
392
- # Page content validation
393
- let(:page_content) { File.read('page_objects/pages/checkout.rb') }
394
-
395
391
  include_examples 'valid scaffolded page'
396
392
  include_examples "#{automation} scaffolded page"
397
393
 
@@ -459,16 +455,10 @@ describe 'Scaffolding E2E' do # rubocop:disable RSpec/DescribeClass
459
455
 
460
456
  it 'generates multiple scaffolds at once' do
461
457
  scaffold.new.invoke(:scaffold, nil, %w[search filter])
462
- expect(Pathname.new('page_objects/pages/search.rb')).to be_file
463
- expect(Pathname.new('page_objects/pages/filter.rb')).to be_file
464
-
465
- if style == :spec
466
- expect(Pathname.new('spec/search_page_spec.rb')).to be_file
467
- expect(Pathname.new('spec/filter_page_spec.rb')).to be_file
468
- else
469
- expect(Pathname.new('features/search.feature')).to be_file
470
- expect(Pathname.new('features/filter.feature')).to be_file
458
+ files = %w[search filter].flat_map do |name|
459
+ ["page_objects/pages/#{name}.rb", style == :spec ? "spec/#{name}_page_spec.rb" : "features/#{name}.feature"]
471
460
  end
461
+ expect(files.reject { |file| File.file?(file) }).to be_empty
472
462
  end
473
463
  end
474
464
 
@@ -496,7 +486,6 @@ describe 'Scaffolding E2E' do # rubocop:disable RSpec/DescribeClass
496
486
  expect(Pathname.new('page_objects/pages/checkout.rb')).to be_file
497
487
  end
498
488
  end
499
-
500
489
  end
501
490
  end
502
491
  end
@@ -33,7 +33,6 @@ RSpec.describe ScaffoldProjectDetector do
33
33
  it 'reports selenium?' do
34
34
  expect(described_class.selenium?).to be true
35
35
  end
36
-
37
36
  end
38
37
 
39
38
  context 'with watir project' do
@@ -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