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.
- checksums.yaml +4 -4
- data/.github/workflows/release.yml +18 -29
- data/.github/workflows/security.yml +68 -0
- data/.rubocop.yml +28 -0
- data/README.md +23 -4
- data/docs/superpowers/plans/2026-10-25-failure-video-replay.md +400 -0
- data/docs/superpowers/plans/2026-10-26-parity-cleanup.md +233 -0
- data/docs/superpowers/plans/2026-10-28-failure-card.md +295 -0
- data/docs/superpowers/specs/2026-10-25-failure-video-replay-design.md +214 -0
- data/docs/superpowers/specs/2026-10-28-failure-card-design.md +210 -0
- data/lib/commands/scaffolding_commands.rb +3 -3
- data/lib/commands/utility_commands.rb +36 -6
- data/lib/generators/cucumber/templates/cucumber.tt +1 -1
- data/lib/generators/cucumber/templates/feature.tt +5 -0
- data/lib/generators/cucumber/templates/partials/appium_env.tt +6 -0
- data/lib/generators/cucumber/templates/partials/selenium_env.tt +22 -0
- data/lib/generators/cucumber/templates/partials/watir_env.tt +22 -0
- data/lib/generators/cucumber/templates/partials/web_steps.tt +4 -0
- data/lib/generators/helper_generator.rb +12 -1
- data/lib/generators/infrastructure/azure_generator.rb +1 -0
- data/lib/generators/infrastructure/github_generator.rb +1 -0
- data/lib/generators/infrastructure/templates/azure_pipelines.tt +12 -1
- data/lib/generators/infrastructure/templates/github.tt +12 -0
- data/lib/generators/infrastructure/templates/github_appium.tt +23 -0
- data/lib/generators/invoke_generators.rb +20 -2
- data/lib/generators/rspec/rspec_generator.rb +0 -1
- data/lib/generators/rspec/templates/spec.tt +12 -0
- data/lib/generators/templates/common/gemfile.tt +1 -1
- data/lib/generators/templates/common/git_ignore.tt +7 -1
- data/lib/generators/templates/common/partials/mobile_config.tt +5 -0
- data/lib/generators/templates/common/partials/web_config.tt +9 -1
- data/lib/generators/templates/common/read_me.tt +84 -2
- data/lib/generators/templates/helpers/browser_helper.tt +5 -1
- data/lib/generators/templates/helpers/driver_helper.tt +1 -0
- data/lib/generators/templates/helpers/partials/selenium_driver.tt +4 -1
- data/lib/generators/templates/helpers/session_state.tt +206 -0
- data/lib/generators/templates/helpers/spec_helper.tt +28 -0
- data/lib/generators/templates/helpers/video_helper.tt +338 -171
- data/lib/ruby_raider/ffmpeg.rb +280 -0
- data/lib/ruby_raider.rb +2 -3
- data/lib/scaffolding/project_detector.rb +1 -3
- data/lib/scaffolding/scaffolding.rb +0 -1
- data/lib/utilities/utilities.rb +15 -2
- data/lib/version +1 -1
- data/ruby_raider.gemspec +4 -1
- data/sig/commands/utility_commands.rbs +4 -1
- data/sig/generators/invoke_generators.rbs +2 -3
- data/sig/ruby_raider/ffmpeg.rbs +23 -0
- data/sig/utilities/utilities.rbs +1 -0
- data/spec/commands/raider_commands_spec.rb +56 -1
- data/spec/generators/fixtures/video_helper_stubs/selenium-webdriver.rb +28 -0
- data/spec/generators/video_helper_spec.rb +608 -0
- data/spec/integration/commands/browser_update_after_creation_spec.rb +7 -6
- data/spec/integration/commands/utility_commands_spec.rb +2 -2
- data/spec/integration/content/azure_content_spec.rb +33 -0
- data/spec/integration/content/ci_content_spec.rb +54 -0
- data/spec/integration/content/common_content_spec.rb +35 -0
- data/spec/integration/content/config_content_spec.rb +32 -0
- data/spec/integration/content/cucumber_reporter_content_spec.rb +47 -1
- data/spec/integration/content/gemfile_content_spec.rb +2 -3
- data/spec/integration/content/helper_content_spec.rb +178 -0
- data/spec/integration/content/page_content_spec.rb +0 -1
- data/spec/integration/content/readme_content_spec.rb +79 -0
- data/spec/integration/content/rspec_reporter_content_spec.rb +2 -2
- data/spec/integration/content/syntax_validation_spec.rb +15 -0
- data/spec/integration/content/test_content_spec.rb +27 -3
- data/spec/integration/end_to_end_features_spec.rb +0 -2
- data/spec/integration/end_to_end_spec.rb +275 -26
- data/spec/integration/generators/azure_generator_spec.rb +7 -6
- data/spec/integration/generators/common_generator_spec.rb +1 -1
- data/spec/integration/generators/helpers_generator_spec.rb +22 -0
- data/spec/integration/generators/invoke_generators_spec.rb +52 -0
- data/spec/integration/helpers/session_state_spec.rb +428 -0
- data/spec/integration/scaffolding_e2e_spec.rb +13 -24
- data/spec/scaffolding/scaffold_project_detector_spec.rb +0 -1
- data/spec/scaffolding/scaffolding_features_spec.rb +0 -1
- data/spec/system/selenium_spec.rb +0 -1
- data/spec/system/watir_spec.rb +0 -1
- data/spec/utilities/ffmpeg_spec.rb +424 -0
- data/spec/utilities/utilities_spec.rb +19 -2
- data/spec/utilities/video_command_spec.rb +120 -0
- metadata +65 -11
- data/lib/commands/adopt_commands.rb +0 -42
- data/lib/commands/loaded_commands.rb +0 -9
- data/lib/commands/plugin_commands.rb +0 -32
- data/sig/commands/adopt_commands.rbs +0 -8
- data/sig/commands/loaded_commands.rbs +0 -5
- data/sig/commands/plugin_commands.rbs +0 -9
data/spec/system/watir_spec.rb
CHANGED
|
@@ -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 '
|
|
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
|
|
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
|