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
|
@@ -1,270 +1,437 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
|
+
<%- unless mobile? -%>
|
|
5
|
+
require 'open3'
|
|
6
|
+
require 'selenium-webdriver'
|
|
7
|
+
<%- end -%>
|
|
8
|
+
require 'tmpdir'
|
|
4
9
|
require 'yaml'
|
|
5
|
-
require 'json'
|
|
6
|
-
require 'base64'
|
|
7
10
|
|
|
11
|
+
# Records each test and keeps the video only when the test fails.
|
|
12
|
+
<%- if mobile? -%>
|
|
13
|
+
# Appium records the device screen natively (mp4).
|
|
14
|
+
<%- else -%>
|
|
15
|
+
# Web tests capture a page screenshot after each navigation, click and text
|
|
16
|
+
# change (FrameListener), plus one final frame, and only failing tests are
|
|
17
|
+
# encoded to WebM with the ffmpeg provisioned by the ruby_raider gem.
|
|
18
|
+
# When no ffmpeg can be obtained, recording is off from the first driver on.
|
|
19
|
+
<%- end -%>
|
|
20
|
+
# Any recorder error prints one [video] warning and turns recording off for
|
|
21
|
+
# the rest of the run; it never raises into a test. VIDEO=off skips recording.
|
|
8
22
|
module VideoHelper
|
|
9
|
-
|
|
23
|
+
DEFAULTS = { 'enabled' => true, 'fps' => 5, 'max_width' => 1280 }.freeze
|
|
24
|
+
<%- unless mobile? -%>
|
|
25
|
+
MAX_FRAME_SECONDS = 2.0
|
|
26
|
+
ENCODE_TIMEOUT = 120
|
|
27
|
+
NO_FFMPEG = 'no ffmpeg available for this platform yet (set RAIDER_FFMPEG_PATH, or run ' \
|
|
28
|
+
'`raider u video install` once the ffmpeg release is published)'
|
|
29
|
+
<%- end -%>
|
|
10
30
|
|
|
11
|
-
|
|
31
|
+
@disabled = false
|
|
32
|
+
@warned = false
|
|
33
|
+
<%- unless mobile? -%>
|
|
34
|
+
@active_recorder = nil
|
|
35
|
+
@ffmpeg_obtainable = nil
|
|
36
|
+
<%- end -%>
|
|
12
37
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
38
|
+
class << self
|
|
39
|
+
<%- unless mobile? -%>
|
|
40
|
+
attr_accessor :active_recorder
|
|
41
|
+
|
|
42
|
+
<%- end -%>
|
|
43
|
+
def config
|
|
44
|
+
@config ||= DEFAULTS.merge(video_config)
|
|
19
45
|
end
|
|
20
|
-
|
|
46
|
+
<%- if mobile? -%>
|
|
21
47
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
48
|
+
# Seconds of device recording per test; video.time_limit in config.yml overrides.
|
|
49
|
+
def time_limit
|
|
50
|
+
configured = config['time_limit'].to_i
|
|
51
|
+
return configured if configured.positive?
|
|
25
52
|
|
|
26
|
-
|
|
27
|
-
|
|
53
|
+
limits = AppiumRecorder::TIME_LIMITS
|
|
54
|
+
limits.fetch(platform, limits['android'])
|
|
55
|
+
end
|
|
28
56
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
57
|
+
def platform
|
|
58
|
+
<%- if cross_platform? -%>
|
|
59
|
+
YAML.load_file('config/config.yml')['platform'].to_s.delete_prefix(':')
|
|
60
|
+
rescue StandardError
|
|
61
|
+
''
|
|
62
|
+
<%- else -%>
|
|
63
|
+
'<%= android? ? 'android' : 'ios' %>'
|
|
35
64
|
<%- end -%>
|
|
36
|
-
when 'auto' then auto_detect(driver_or_browser)
|
|
37
|
-
else NullRecorder.new
|
|
38
65
|
end
|
|
39
|
-
|
|
66
|
+
<%- end -%>
|
|
67
|
+
|
|
68
|
+
def enabled?
|
|
69
|
+
config['enabled'] != false && ENV['VIDEO'] != 'off' && !@disabled
|
|
70
|
+
end
|
|
40
71
|
|
|
41
|
-
|
|
42
|
-
sel_driver = resolve_selenium_driver(driver_or_browser)
|
|
72
|
+
def recorder_for(driver_or_browser)
|
|
43
73
|
<%- if mobile? -%>
|
|
44
|
-
|
|
74
|
+
return NullRecorder.new unless enabled?
|
|
75
|
+
<%- else -%>
|
|
76
|
+
return NullRecorder.new unless enabled? && ffmpeg_obtainable?
|
|
45
77
|
<%- end -%>
|
|
46
|
-
return CdpRecorder.new(sel_driver) if chrome_or_edge?(sel_driver)
|
|
47
78
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
79
|
+
<%- if mobile? -%>
|
|
80
|
+
AppiumRecorder.new(driver_or_browser)
|
|
81
|
+
<%- else -%>
|
|
82
|
+
FrameRecorder.new(selenium_driver(driver_or_browser))
|
|
83
|
+
<%- end -%>
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
disable!("could not create a recorder: #{e.message}")
|
|
51
86
|
NullRecorder.new
|
|
52
87
|
end
|
|
53
|
-
end
|
|
54
88
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
89
|
+
# True when the example passed or no retry is left, so a failed attempt
|
|
90
|
+
# that will be retried does not pay for an encode. rspec-retry sets
|
|
91
|
+
# metadata[:retry_attempts] (0-based) before each attempt, and after(:each)
|
|
92
|
+
# hooks run inside that attempt.
|
|
93
|
+
def final_attempt?(example)
|
|
94
|
+
return true unless example.exception
|
|
95
|
+
|
|
96
|
+
example.metadata[:retry_attempts].to_i + 1 >= retry_count(example)
|
|
62
97
|
end
|
|
63
|
-
rescue StandardError
|
|
64
|
-
obj
|
|
65
|
-
end
|
|
66
98
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
99
|
+
# A nil reason means the cause was already reported (by RubyRaider::Ffmpeg).
|
|
100
|
+
def disable!(reason = nil)
|
|
101
|
+
@disabled = true
|
|
102
|
+
<%- unless mobile? -%>
|
|
103
|
+
@active_recorder = nil
|
|
104
|
+
<%- end -%>
|
|
105
|
+
return nil if @warned
|
|
73
106
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
107
|
+
@warned = true
|
|
108
|
+
say("#{reason}; recording disabled for this run") if reason
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
<%- unless mobile? -%>
|
|
78
112
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
113
|
+
# Passed to Selenium as `listener:` when the driver is created.
|
|
114
|
+
def listener
|
|
115
|
+
return nil unless enabled? && ffmpeg_obtainable?
|
|
116
|
+
|
|
117
|
+
@listener ||= FrameListener.new
|
|
118
|
+
end
|
|
82
119
|
|
|
83
|
-
|
|
120
|
+
def release(recorder)
|
|
121
|
+
@active_recorder = nil if @active_recorder.equal?(recorder)
|
|
122
|
+
end
|
|
84
123
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
@frames = []
|
|
89
|
-
@recording = false
|
|
124
|
+
def fps
|
|
125
|
+
value = config['fps'].to_f
|
|
126
|
+
value.positive? ? value : DEFAULTS['fps']
|
|
90
127
|
end
|
|
91
128
|
|
|
92
|
-
def
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
@frame_index = 0
|
|
96
|
-
@recording = true
|
|
97
|
-
@driver.execute_cdp('Page.startScreencast',
|
|
98
|
-
format: 'jpeg', quality: 60,
|
|
99
|
-
maxWidth: 1280, maxHeight: 720,
|
|
100
|
-
everyNthFrame: 2)
|
|
101
|
-
start_frame_collector
|
|
102
|
-
rescue StandardError => e
|
|
103
|
-
warn "[video] CDP recording failed to start: #{e.message}"
|
|
104
|
-
@recording = false
|
|
129
|
+
def max_width
|
|
130
|
+
value = config['max_width'].to_i
|
|
131
|
+
value.positive? ? value : DEFAULTS['max_width']
|
|
105
132
|
end
|
|
106
133
|
|
|
107
|
-
def
|
|
108
|
-
|
|
134
|
+
def monotonic_now
|
|
135
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
136
|
+
end
|
|
109
137
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
138
|
+
# A screenshot while an alert is open would dismiss it, so skip that frame.
|
|
139
|
+
# An alert opened between this check and the screenshot can still be hit.
|
|
140
|
+
def alert_open?(driver)
|
|
141
|
+
driver.switch_to.alert
|
|
142
|
+
true
|
|
143
|
+
rescue Selenium::WebDriver::Error::NoSuchAlertError
|
|
144
|
+
false
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# frames: [[file name, monotonic time], ...] inside dir. Returns the video path or nil.
|
|
148
|
+
def encode(dir, frames)
|
|
149
|
+
ffmpeg = ffmpeg_path
|
|
150
|
+
return nil unless ffmpeg
|
|
151
|
+
|
|
152
|
+
list = File.join(dir, 'list.txt')
|
|
153
|
+
File.write(list, concat_list(frames))
|
|
154
|
+
output = File.join(dir, 'video.webm')
|
|
155
|
+
ok, log = run_ffmpeg(ffmpeg_arguments(ffmpeg, list, output))
|
|
156
|
+
return output if ok && File.size?(output)
|
|
157
|
+
|
|
158
|
+
disable!("ffmpeg could not encode the video: #{log.to_s.strip.lines.last || 'no output'}")
|
|
117
159
|
end
|
|
160
|
+
<%- end -%>
|
|
118
161
|
|
|
119
162
|
private
|
|
120
163
|
|
|
121
|
-
def
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
164
|
+
def video_config
|
|
165
|
+
video = YAML.load_file('config/config.yml')['video']
|
|
166
|
+
video.is_a?(Hash) ? video : {}
|
|
167
|
+
rescue StandardError
|
|
168
|
+
{}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Same precedence as rspec-retry, which never retries fewer than once.
|
|
172
|
+
def retry_count(example)
|
|
173
|
+
configured = ENV['RSPEC_RETRY_RETRY_COUNT'] || example.metadata[:retry] || default_retry_count
|
|
174
|
+
[configured.to_i, 1].max
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def default_retry_count
|
|
178
|
+
RSpec.configuration.respond_to?(:default_retry_count) ? RSpec.configuration.default_retry_count : 0
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# $stderr.puts, not Kernel#warn: warn is silenced under -W0.
|
|
182
|
+
# rubocop:disable Style/StderrPuts
|
|
183
|
+
def say(message)
|
|
184
|
+
$stderr.puts("[video] #{message}")
|
|
185
|
+
end
|
|
186
|
+
# rubocop:enable Style/StderrPuts
|
|
187
|
+
<%- unless mobile? -%>
|
|
188
|
+
|
|
189
|
+
# Watir::Browser wraps the Selenium driver.
|
|
190
|
+
def selenium_driver(driver_or_browser)
|
|
191
|
+
driver_or_browser.respond_to?(:wd) ? driver_or_browser.wd : driver_or_browser
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Decided once per process, at the first listener/recorder_for call, so a run
|
|
195
|
+
# that can never get an ffmpeg warns at the start and pays no capture cost.
|
|
196
|
+
def ffmpeg_obtainable?
|
|
197
|
+
return @ffmpeg_obtainable unless @ffmpeg_obtainable.nil?
|
|
198
|
+
|
|
199
|
+
@ffmpeg_obtainable = check_ffmpeg_obtainable
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def check_ffmpeg_obtainable
|
|
203
|
+
require 'ruby_raider/ffmpeg'
|
|
204
|
+
return true if RubyRaider::Ffmpeg.obtainable?
|
|
205
|
+
|
|
206
|
+
disable!(NO_FFMPEG)
|
|
207
|
+
false
|
|
208
|
+
rescue LoadError, NoMethodError
|
|
209
|
+
disable!('ruby_raider gem too old for video; update it')
|
|
210
|
+
false
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def ffmpeg_path
|
|
214
|
+
require 'ruby_raider/ffmpeg'
|
|
215
|
+
RubyRaider::Ffmpeg.ensure! || disable!
|
|
216
|
+
rescue LoadError
|
|
217
|
+
disable!('ruby_raider gem too old for video; update it')
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Concat demuxer list: each frame lasts until the next one, clamped to
|
|
221
|
+
# [1/fps, MAX_FRAME_SECONDS], so playback follows the test's real time.
|
|
222
|
+
def concat_list(frames)
|
|
223
|
+
shortest = 1.0 / fps
|
|
224
|
+
lines = frames.each_with_index.flat_map do |(name, time), index|
|
|
225
|
+
following = frames[index + 1]
|
|
226
|
+
duration = following ? (following[1] - time).clamp(shortest, MAX_FRAME_SECONDS) : shortest
|
|
227
|
+
["file '#{name}'", format('duration %.3f', duration)]
|
|
131
228
|
end
|
|
229
|
+
(lines << "file '#{frames.last[0]}'").join("\n") << "\n"
|
|
132
230
|
end
|
|
133
231
|
|
|
134
|
-
def
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
frame_path = File.join(VideoHelper::VIDEO_DIR, "#{@test_name}_frame_#{format('%04d', @frame_index)}.jpg")
|
|
139
|
-
@driver.save_screenshot(frame_path)
|
|
140
|
-
@frames << frame_path
|
|
141
|
-
@frame_index += 1
|
|
232
|
+
def ffmpeg_arguments(ffmpeg, list, output)
|
|
233
|
+
[ffmpeg, '-y', '-loglevel', 'error', '-f', 'concat', '-safe', '0', '-i', list,
|
|
234
|
+
'-vf', "scale='min(#{max_width},iw)':-2", '-c:v', 'libvpx', '-deadline', 'realtime',
|
|
235
|
+
'-b:v', '1M', '-r', format('%g', fps), '-pix_fmt', 'yuv420p', output]
|
|
142
236
|
end
|
|
143
237
|
|
|
144
|
-
|
|
145
|
-
|
|
238
|
+
# ffmpeg may exit between the join timeout and the kill.
|
|
239
|
+
def kill(process)
|
|
240
|
+
Process.kill('KILL', process.pid)
|
|
241
|
+
rescue Errno::ESRCH
|
|
242
|
+
nil
|
|
243
|
+
end
|
|
146
244
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
245
|
+
def run_ffmpeg(arguments)
|
|
246
|
+
Open3.popen2e(*arguments) do |stdin, output, process|
|
|
247
|
+
stdin.close
|
|
248
|
+
reader = Thread.new { output.read }
|
|
249
|
+
reader.report_on_exception = false
|
|
250
|
+
return [process.value.success?, reader.value] if process.join(ENCODE_TIMEOUT)
|
|
251
|
+
|
|
252
|
+
kill(process)
|
|
253
|
+
process.value
|
|
254
|
+
reader.join(1)
|
|
255
|
+
[false, "ffmpeg timed out after #{ENCODE_TIMEOUT} seconds"]
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
<%- end -%>
|
|
259
|
+
end
|
|
260
|
+
<%- unless mobile? -%>
|
|
151
261
|
|
|
152
|
-
|
|
153
|
-
|
|
262
|
+
# Forwards the state-changing Selenium events to the active FrameRecorder.
|
|
263
|
+
# One recorded driver per process: every frame goes to the active recorder.
|
|
264
|
+
class FrameListener < Selenium::WebDriver::Support::AbstractEventListener
|
|
265
|
+
def after_navigate_to(_url, driver)
|
|
266
|
+
capture(driver)
|
|
267
|
+
end
|
|
154
268
|
|
|
155
|
-
|
|
156
|
-
|
|
269
|
+
def after_click(_element, driver)
|
|
270
|
+
capture(driver)
|
|
157
271
|
end
|
|
158
272
|
|
|
159
|
-
def
|
|
160
|
-
|
|
273
|
+
def after_change_value_of(_element, driver)
|
|
274
|
+
capture(driver)
|
|
161
275
|
end
|
|
162
276
|
|
|
163
|
-
|
|
164
|
-
|
|
277
|
+
private
|
|
278
|
+
|
|
279
|
+
def capture(driver)
|
|
280
|
+
VideoHelper.active_recorder&.capture(driver)
|
|
281
|
+
nil
|
|
282
|
+
rescue StandardError => e
|
|
283
|
+
VideoHelper.disable!("frame capture failed: #{e.message}")
|
|
165
284
|
end
|
|
166
285
|
end
|
|
167
286
|
|
|
168
|
-
#
|
|
287
|
+
# Saves frames to its own tmpdir; encodes them only when the test failed.
|
|
288
|
+
class FrameRecorder
|
|
289
|
+
attr_reader :dir
|
|
169
290
|
|
|
170
|
-
|
|
171
|
-
|
|
291
|
+
def initialize(driver)
|
|
292
|
+
@driver = driver
|
|
293
|
+
@frames = []
|
|
172
294
|
@recording = false
|
|
173
295
|
end
|
|
174
296
|
|
|
175
|
-
def start
|
|
176
|
-
@
|
|
177
|
-
|
|
178
|
-
@output = File.join(VideoHelper::VIDEO_DIR, "#{@test_name}.mp4")
|
|
297
|
+
def start
|
|
298
|
+
@dir = Dir.mktmpdir('raider-video')
|
|
299
|
+
@frames = []
|
|
179
300
|
@recording = true
|
|
301
|
+
VideoHelper.active_recorder = self
|
|
302
|
+
nil
|
|
303
|
+
rescue StandardError => e
|
|
304
|
+
VideoHelper.disable!("recording failed to start: #{e.message}")
|
|
305
|
+
discard
|
|
306
|
+
end
|
|
180
307
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
308
|
+
def capture(driver = @driver)
|
|
309
|
+
return nil unless @recording && VideoHelper.enabled?
|
|
310
|
+
|
|
311
|
+
snap(driver)
|
|
185
312
|
rescue StandardError => e
|
|
186
|
-
|
|
187
|
-
@recording = false
|
|
313
|
+
VideoHelper.disable!("frame capture failed: #{e.message}")
|
|
188
314
|
end
|
|
189
315
|
|
|
190
|
-
def stop
|
|
316
|
+
def stop(keep:)
|
|
191
317
|
return nil unless @recording
|
|
192
318
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
319
|
+
final_frame if keep
|
|
320
|
+
finish
|
|
321
|
+
return discard unless keep && VideoHelper.enabled? && @frames.any?
|
|
322
|
+
|
|
323
|
+
VideoHelper.encode(@dir, @frames) || discard
|
|
197
324
|
rescue StandardError => e
|
|
198
|
-
|
|
325
|
+
finish
|
|
326
|
+
VideoHelper.disable!("recording failed to stop: #{e.message}")
|
|
327
|
+
discard
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def cleanup
|
|
331
|
+
FileUtils.rm_rf(@dir) if @dir
|
|
199
332
|
nil
|
|
333
|
+
rescue StandardError => e
|
|
334
|
+
VideoHelper.disable!("could not remove video frames: #{e.message}")
|
|
200
335
|
end
|
|
201
336
|
|
|
202
337
|
private
|
|
203
338
|
|
|
204
|
-
def
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
'-f gdigrab -i desktop'
|
|
213
|
-
else
|
|
214
|
-
'-f x11grab -i :0'
|
|
215
|
-
end
|
|
339
|
+
def snap(driver)
|
|
340
|
+
return nil if VideoHelper.alert_open?(driver)
|
|
341
|
+
|
|
342
|
+
name = format('frame-%06d.png', @frames.size + 1)
|
|
343
|
+
time = VideoHelper.monotonic_now
|
|
344
|
+
driver.save_screenshot(File.join(@dir, name))
|
|
345
|
+
@frames << [name, time]
|
|
346
|
+
nil
|
|
216
347
|
end
|
|
217
348
|
|
|
218
|
-
|
|
219
|
-
|
|
349
|
+
# The browser is often gone after a failure; keep the frames already saved.
|
|
350
|
+
def final_frame
|
|
351
|
+
snap(@driver) if VideoHelper.enabled?
|
|
352
|
+
rescue StandardError
|
|
353
|
+
nil
|
|
220
354
|
end
|
|
221
|
-
end
|
|
222
355
|
|
|
223
|
-
|
|
224
|
-
|
|
356
|
+
def finish
|
|
357
|
+
@recording = false
|
|
358
|
+
VideoHelper.release(self)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def discard
|
|
362
|
+
cleanup
|
|
363
|
+
nil
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
<%- else -%>
|
|
225
367
|
|
|
368
|
+
# Appium's native screen recording; the mp4 is written as-is. It needs ffmpeg on
|
|
369
|
+
# the Appium server host for iOS, and for Android recordings over 180 s.
|
|
226
370
|
class AppiumRecorder
|
|
371
|
+
# Android keeps Appium's 180 s default so it works without ffmpeg on the Appium host.
|
|
372
|
+
TIME_LIMITS = { 'ios' => 1800, 'android' => 180 }.freeze
|
|
373
|
+
|
|
374
|
+
attr_reader :dir
|
|
375
|
+
|
|
227
376
|
def initialize(driver)
|
|
228
|
-
|
|
377
|
+
# appium_lib's Appium::Driver keeps the recording API on its core driver.
|
|
378
|
+
@driver = driver.respond_to?(:start_recording_screen) ? driver : driver.driver
|
|
229
379
|
@recording = false
|
|
230
380
|
end
|
|
231
381
|
|
|
232
|
-
def start
|
|
233
|
-
@
|
|
382
|
+
def start
|
|
383
|
+
@dir = Dir.mktmpdir('raider-video')
|
|
384
|
+
@driver.start_recording_screen(time_limit: VideoHelper.time_limit)
|
|
234
385
|
@recording = true
|
|
235
|
-
|
|
386
|
+
nil
|
|
236
387
|
rescue StandardError => e
|
|
237
|
-
|
|
238
|
-
|
|
388
|
+
VideoHelper.disable!("recording failed to start: #{e.message}")
|
|
389
|
+
discard
|
|
239
390
|
end
|
|
240
391
|
|
|
241
|
-
def stop
|
|
392
|
+
def stop(keep:)
|
|
242
393
|
return nil unless @recording
|
|
243
394
|
|
|
244
395
|
@recording = false
|
|
245
|
-
|
|
246
|
-
VideoHelper.
|
|
247
|
-
|
|
248
|
-
File.
|
|
249
|
-
|
|
396
|
+
video = @driver.stop_recording_screen
|
|
397
|
+
return discard unless keep && VideoHelper.enabled? && video && !video.empty?
|
|
398
|
+
|
|
399
|
+
path = File.join(@dir, 'video.mp4')
|
|
400
|
+
File.binwrite(path, video.unpack1('m'))
|
|
401
|
+
path
|
|
250
402
|
rescue StandardError => e
|
|
251
|
-
|
|
403
|
+
VideoHelper.disable!("recording failed to stop: #{e.message}")
|
|
404
|
+
discard
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def cleanup
|
|
408
|
+
FileUtils.rm_rf(@dir) if @dir
|
|
252
409
|
nil
|
|
410
|
+
rescue StandardError => e
|
|
411
|
+
VideoHelper.disable!("could not remove the video: #{e.message}")
|
|
253
412
|
end
|
|
254
413
|
|
|
255
414
|
private
|
|
256
415
|
|
|
257
|
-
def
|
|
258
|
-
|
|
416
|
+
def discard
|
|
417
|
+
cleanup
|
|
418
|
+
nil
|
|
259
419
|
end
|
|
260
420
|
end
|
|
261
|
-
|
|
262
421
|
<%- end -%>
|
|
263
|
-
# --- Null Recorder: Fallback when recording unavailable ---
|
|
264
422
|
|
|
423
|
+
# Used when recording is off or unavailable.
|
|
265
424
|
class NullRecorder
|
|
266
|
-
def start
|
|
425
|
+
def start
|
|
426
|
+
nil
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def stop(**)
|
|
430
|
+
nil
|
|
431
|
+
end
|
|
267
432
|
|
|
268
|
-
def
|
|
433
|
+
def cleanup
|
|
434
|
+
nil
|
|
435
|
+
end
|
|
269
436
|
end
|
|
270
437
|
end
|