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