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.
- 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 +22 -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/partials/appium_env.tt +6 -0
- data/lib/generators/cucumber/templates/partials/selenium_env.tt +6 -0
- data/lib/generators/cucumber/templates/partials/watir_env.tt +6 -0
- data/lib/generators/helper_generator.rb +5 -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 +26 -2
- data/lib/generators/infrastructure/templates/github.tt +31 -1
- data/lib/generators/infrastructure/templates/github_appium.tt +50 -1
- data/lib/generators/invoke_generators.rb +20 -2
- data/lib/generators/rspec/rspec_generator.rb +0 -1
- data/lib/generators/templates/common/gemfile.tt +1 -1
- data/lib/generators/templates/common/git_ignore.tt +3 -0
- data/lib/generators/templates/common/partials/mobile_config.tt +5 -0
- data/lib/generators/templates/common/partials/web_config.tt +6 -1
- data/lib/generators/templates/common/read_me.tt +80 -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/spec_helper.tt +8 -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 +3 -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 +48 -3
- data/spec/integration/content/ci_content_spec.rb +115 -5
- data/spec/integration/content/common_content_spec.rb +8 -0
- data/spec/integration/content/config_content_spec.rb +14 -0
- data/spec/integration/content/cucumber_reporter_content_spec.rb +22 -1
- data/spec/integration/content/gemfile_content_spec.rb +2 -3
- data/spec/integration/content/helper_content_spec.rb +146 -0
- data/spec/integration/content/page_content_spec.rb +0 -1
- data/spec/integration/content/readme_content_spec.rb +65 -0
- data/spec/integration/content/rspec_reporter_content_spec.rb +2 -2
- data/spec/integration/content/test_content_spec.rb +2 -3
- data/spec/integration/end_to_end_features_spec.rb +0 -2
- data/spec/integration/end_to_end_spec.rb +252 -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/invoke_generators_spec.rb +52 -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 +49 -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
|
@@ -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
|
|
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)
|
|
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")
|
|
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(
|
|
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
|
-
|
|
330
|
-
|
|
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
|
|
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
|
-
|
|
463
|
-
|
|
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
|
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
|