ruby_raider 3.8.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +18 -29
  3. data/.github/workflows/security.yml +68 -0
  4. data/.rubocop.yml +28 -0
  5. data/README.md +22 -4
  6. data/docs/superpowers/plans/2026-10-25-failure-video-replay.md +400 -0
  7. data/docs/superpowers/plans/2026-10-26-parity-cleanup.md +233 -0
  8. data/docs/superpowers/plans/2026-10-28-failure-card.md +295 -0
  9. data/docs/superpowers/specs/2026-10-25-failure-video-replay-design.md +214 -0
  10. data/docs/superpowers/specs/2026-10-28-failure-card-design.md +210 -0
  11. data/lib/commands/scaffolding_commands.rb +3 -3
  12. data/lib/commands/utility_commands.rb +36 -6
  13. data/lib/generators/cucumber/templates/cucumber.tt +1 -1
  14. data/lib/generators/cucumber/templates/partials/appium_env.tt +6 -0
  15. data/lib/generators/cucumber/templates/partials/selenium_env.tt +6 -0
  16. data/lib/generators/cucumber/templates/partials/watir_env.tt +6 -0
  17. data/lib/generators/helper_generator.rb +5 -1
  18. data/lib/generators/infrastructure/azure_generator.rb +1 -0
  19. data/lib/generators/infrastructure/github_generator.rb +1 -0
  20. data/lib/generators/infrastructure/templates/azure_pipelines.tt +26 -2
  21. data/lib/generators/infrastructure/templates/github.tt +31 -1
  22. data/lib/generators/infrastructure/templates/github_appium.tt +50 -1
  23. data/lib/generators/invoke_generators.rb +20 -2
  24. data/lib/generators/rspec/rspec_generator.rb +0 -1
  25. data/lib/generators/templates/common/gemfile.tt +1 -1
  26. data/lib/generators/templates/common/git_ignore.tt +3 -0
  27. data/lib/generators/templates/common/partials/mobile_config.tt +5 -0
  28. data/lib/generators/templates/common/partials/web_config.tt +6 -1
  29. data/lib/generators/templates/common/read_me.tt +80 -2
  30. data/lib/generators/templates/helpers/browser_helper.tt +5 -1
  31. data/lib/generators/templates/helpers/driver_helper.tt +1 -0
  32. data/lib/generators/templates/helpers/partials/selenium_driver.tt +4 -1
  33. data/lib/generators/templates/helpers/spec_helper.tt +8 -0
  34. data/lib/generators/templates/helpers/video_helper.tt +338 -171
  35. data/lib/ruby_raider/ffmpeg.rb +280 -0
  36. data/lib/ruby_raider.rb +2 -3
  37. data/lib/scaffolding/project_detector.rb +1 -3
  38. data/lib/scaffolding/scaffolding.rb +0 -1
  39. data/lib/utilities/utilities.rb +15 -2
  40. data/lib/version +1 -1
  41. data/ruby_raider.gemspec +3 -1
  42. data/sig/commands/utility_commands.rbs +4 -1
  43. data/sig/generators/invoke_generators.rbs +2 -3
  44. data/sig/ruby_raider/ffmpeg.rbs +23 -0
  45. data/sig/utilities/utilities.rbs +1 -0
  46. data/spec/commands/raider_commands_spec.rb +56 -1
  47. data/spec/generators/fixtures/video_helper_stubs/selenium-webdriver.rb +28 -0
  48. data/spec/generators/video_helper_spec.rb +608 -0
  49. data/spec/integration/commands/browser_update_after_creation_spec.rb +7 -6
  50. data/spec/integration/commands/utility_commands_spec.rb +2 -2
  51. data/spec/integration/content/azure_content_spec.rb +48 -3
  52. data/spec/integration/content/ci_content_spec.rb +115 -5
  53. data/spec/integration/content/common_content_spec.rb +8 -0
  54. data/spec/integration/content/config_content_spec.rb +14 -0
  55. data/spec/integration/content/cucumber_reporter_content_spec.rb +22 -1
  56. data/spec/integration/content/gemfile_content_spec.rb +2 -3
  57. data/spec/integration/content/helper_content_spec.rb +146 -0
  58. data/spec/integration/content/page_content_spec.rb +0 -1
  59. data/spec/integration/content/readme_content_spec.rb +65 -0
  60. data/spec/integration/content/rspec_reporter_content_spec.rb +2 -2
  61. data/spec/integration/content/test_content_spec.rb +2 -3
  62. data/spec/integration/end_to_end_features_spec.rb +0 -2
  63. data/spec/integration/end_to_end_spec.rb +252 -26
  64. data/spec/integration/generators/azure_generator_spec.rb +7 -6
  65. data/spec/integration/generators/common_generator_spec.rb +1 -1
  66. data/spec/integration/generators/invoke_generators_spec.rb +52 -0
  67. data/spec/integration/scaffolding_e2e_spec.rb +13 -24
  68. data/spec/scaffolding/scaffold_project_detector_spec.rb +0 -1
  69. data/spec/scaffolding/scaffolding_features_spec.rb +0 -1
  70. data/spec/system/selenium_spec.rb +0 -1
  71. data/spec/system/watir_spec.rb +0 -1
  72. data/spec/utilities/ffmpeg_spec.rb +424 -0
  73. data/spec/utilities/utilities_spec.rb +19 -2
  74. data/spec/utilities/video_command_spec.rb +120 -0
  75. metadata +49 -11
  76. data/lib/commands/adopt_commands.rb +0 -42
  77. data/lib/commands/loaded_commands.rb +0 -9
  78. data/lib/commands/plugin_commands.rb +0 -32
  79. data/sig/commands/adopt_commands.rbs +0 -8
  80. data/sig/commands/loaded_commands.rbs +0 -5
  81. data/sig/commands/plugin_commands.rbs +0 -9
@@ -3,6 +3,7 @@
3
3
  require 'yaml'
4
4
  require 'fileutils'
5
5
  require_relative 'content_helper'
6
+ require_relative '../../../lib/ruby_raider/ffmpeg'
6
7
 
7
8
  # UseRubyVersion@0 versionSpec '3.4' resolves against the hosted tool cache, which lists Ruby 3.4.x
8
9
  # (3.2, 3.3, 3.4, 4.0): https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md
@@ -47,6 +48,33 @@ describe 'Azure Pipelines content' do
47
48
  )
48
49
  end
49
50
 
51
+ it 'caches the ffmpeg dir keyed on the ffmpeg version and Gemfile.lock, then installs ffmpeg, before the tests' do
52
+ steps = yml['jobs'][0]['steps']
53
+ cache = steps.index { |s| s['task'] == 'Cache@2' }
54
+ install = steps.index { |s| s['script'] == 'bundle exec raider u video install' }
55
+ tests = steps.index { |s| s['script'] == run_command }
56
+ expect([cache, install, tests]).to all(be_an(Integer))
57
+ expect(cache).to be < install
58
+ expect(install).to be < tests
59
+ expect(steps[cache]['inputs']).to eq(
60
+ 'key' => "raider-ffmpeg-#{RubyRaider::Ffmpeg::MANIFEST[:version]} | \"$(Agent.OS)\" | Gemfile.lock",
61
+ 'path' => '$(RAIDER_CACHE_DIR)'
62
+ )
63
+ end
64
+
65
+ it 'caches RAIDER_CACHE_DIR in the pipeline workspace and keeps the ffmpeg install from failing the build' do
66
+ install = yml['jobs'][0]['steps'].find { |s| s['script'] == 'bundle exec raider u video install' }
67
+ expect(install['continueOnError']).to be(true)
68
+ expect(yml['variables']['RAIDER_CACHE_DIR']).to eq('$(Pipeline.Workspace)/.cache/raider')
69
+ end
70
+
71
+ it 'adds no cache or install step to the comment and gate jobs' do
72
+ yml['jobs'][1..].each do |job|
73
+ expect(job['steps'].map { |s| s['task'] || s['script'] })
74
+ .not_to include('Cache@2', 'bundle exec raider u video install')
75
+ end
76
+ end
77
+
50
78
  it 'passes exactly the Raider settings to the test step' do
51
79
  step = yml['jobs'][0]['steps'].find { |s| s['script'] == run_command }
52
80
  expect(step['env']).to eq('RAIDER_TOKEN' => '$(RAIDER_TOKEN)', 'RAIDER_ENDPOINT' => '$(RAIDER_ENDPOINT)',
@@ -54,16 +82,32 @@ describe 'Azure Pipelines content' do
54
82
  expect(yml['variables']['HEADLESS']).to eq('true')
55
83
  end
56
84
 
57
- it 'has a raider_comment job for pull requests' do
85
+ it 'has a raider_comment job for pull requests that waits for the gate job but runs even when it is skipped' do
58
86
  comment = yml['jobs'][1]
59
87
  expect(comment['job']).to eq('raider_comment')
60
- expect(comment['dependsOn']).to eq('tests')
88
+ expect(comment['dependsOn']).to eq(%w[tests raider_gate])
61
89
  expect(comment['condition']).to eq("and(always(), eq(variables['Build.Reason'], 'PullRequest'))")
62
90
  last = comment['steps'].last
63
91
  expect(last['script']).to eq('bundle exec raider-reporter comment')
64
92
  expect(last['env']).to include('SYSTEM_ACCESSTOKEN' => '$(System.AccessToken)')
65
93
  end
66
94
 
95
+ it 'has a raider_gate job that runs only when RAIDER_GATE is 1' do
96
+ gate = yml['jobs'][2]
97
+ expect(yml['jobs'].size).to eq(3)
98
+ expect(gate['job']).to eq('raider_gate')
99
+ expect(gate['dependsOn']).to eq('tests')
100
+ expect(gate['condition']).to eq("and(always(), eq(variables['RAIDER_GATE'], '1'))")
101
+ end
102
+
103
+ it 'sets up Ruby in the raider_gate job and runs the gate with the token env' do
104
+ gate = yml['jobs'][2]
105
+ expect(gate['steps'].first).to eq('task' => 'UseRubyVersion@0', 'inputs' => { 'versionSpec' => '3.4' })
106
+ expect(gate['steps'].filter_map { |s| s['script'] }).to eq(['bundle install', 'bundle exec raider-reporter gate'])
107
+ expect(gate['steps'].last['env']).to eq('RAIDER_TOKEN' => '$(RAIDER_TOKEN)', 'RAIDER_ENDPOINT' => '$(RAIDER_ENDPOINT)',
108
+ 'SYSTEM_ACCESSTOKEN' => '$(System.AccessToken)')
109
+ end
110
+
67
111
  it 'has triggers, pool and the self-hosted hint' do
68
112
  expect(yml['trigger']['branches']['include']).to include('main', 'master')
69
113
  expect(yml['pr']['branches']['include']).to include('*')
@@ -84,8 +128,9 @@ describe 'Azure Pipelines content' do
84
128
  readme = read_generated(name, 'Readme.md')
85
129
  expect(readme).to include('## Azure Pipelines', 'RAIDER_TOKEN', 'Contribute to pull requests',
86
130
  'pool: { name: Default }', 'Build validation branch policy',
87
- 'same stage')
131
+ 'same stage', 'raider_gate', 'RAIDER_GATE=1', 'RAIDER_SHARD_TOTAL', 'RAIDER_SHARD_INDEX')
88
132
  expect(readme).not_to match(/GitHub Actions/i)
133
+ expect(readme).to include('`RAIDER_CACHE_DIR` to `$(Pipeline.Workspace)/.cache/raider`')
89
134
  end
90
135
  end
91
136
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'yaml'
4
4
  require_relative 'content_helper'
5
+ require_relative '../../../lib/ruby_raider/ffmpeg'
5
6
 
6
7
  describe 'CI pipeline content' do
7
8
  shared_examples 'valid github pipeline' do |project_name|
@@ -92,7 +93,7 @@ describe 'CI pipeline content' do
92
93
 
93
94
  it 'uploads raider-results as an artifact even on failure in every job' do
94
95
  uploads = steps.select { |step| step['uses'] == 'actions/upload-artifact@v4' }
95
- expect(uploads.size).to eq(YAML.safe_load(pipeline)['jobs'].except('raider-comment').size)
96
+ expect(uploads.size).to eq(YAML.safe_load(pipeline)['jobs'].except('raider-comment', 'raider-gate').size)
96
97
  uploads.each do |upload|
97
98
  expect(upload['with']['path']).to eq('raider-results/')
98
99
  expect(upload['if']).to eq('always()')
@@ -107,15 +108,16 @@ describe 'CI pipeline content' do
107
108
  let(:job) { jobs['raider-comment'] }
108
109
 
109
110
  it 'is a valid workflow with exactly the expected jobs' do
110
- expect(jobs.keys).to match_array(test_jobs + ['raider-comment'])
111
+ expect(jobs.keys).to match_array(test_jobs + %w[raider-comment raider-gate])
111
112
  end
112
113
 
113
- it 'needs every test job' do
114
- expect(job['needs']).to match_array(test_jobs)
114
+ it 'needs every test job and the gate job, so the comment carries the final gate line' do
115
+ expect(job['needs']).to match_array(test_jobs + %w[raider-gate])
115
116
  end
116
117
 
117
- it 'runs always, on pull requests only' do
118
+ it 'runs always, on pull requests only, so a skipped or failed gate job does not skip it' do
118
119
  expect(job['if']).to eq("always() && github.event_name == 'pull_request'")
120
+ expect(job['if']).to start_with('always()')
119
121
  expect(job['runs-on']).to eq('ubuntu-latest')
120
122
  end
121
123
 
@@ -151,62 +153,170 @@ describe 'CI pipeline content' do
151
153
  end
152
154
  end
153
155
 
156
+ shared_examples 'github raider-gate job' do |project_name, file, test_jobs|
157
+ subject(:pipeline) { read_generated(project_name, ".github/workflows/#{file}") }
158
+
159
+ let(:jobs) { YAML.safe_load(pipeline)['jobs'] }
160
+ let(:gate_job) { jobs['raider-gate'] }
161
+
162
+ it 'needs every test job and runs always when RAIDER_GATE is 1, on push and pull requests' do
163
+ expect(gate_job['needs']).to match_array(test_jobs)
164
+ expect(gate_job['if']).to eq("always() && vars.RAIDER_GATE == '1'")
165
+ expect(gate_job['runs-on']).to eq('ubuntu-latest')
166
+ end
167
+
168
+ it 'can read the code and write statuses only' do
169
+ expect(gate_job['permissions']).to eq('contents' => 'read', 'statuses' => 'write')
170
+ end
171
+
172
+ it 'checks out, sets up the same Ruby as the test jobs and runs the gate' do
173
+ steps = gate_job['steps']
174
+ expect(steps[0]).to eq('uses' => 'actions/checkout@v4')
175
+ test_ruby = jobs.values_at(*test_jobs).flat_map { |j| j['steps'] }
176
+ .find { |step| step['uses'] == 'ruby/setup-ruby@v1' }['with']['ruby-version']
177
+ expect(steps[1]).to eq('uses' => 'ruby/setup-ruby@v1', 'with' => { 'ruby-version' => test_ruby, 'bundler-cache' => true })
178
+ expect(steps[2]).to eq('name' => 'Wait for the Raider quality gate',
179
+ 'run' => 'bundle exec raider-reporter gate',
180
+ 'env' => { 'RAIDER_TOKEN' => '${{ secrets.RAIDER_TOKEN }}',
181
+ 'RAIDER_ENDPOINT' => '${{ vars.RAIDER_ENDPOINT }}',
182
+ 'GITHUB_TOKEN' => '${{ secrets.GITHUB_TOKEN }}' })
183
+ expect(steps.size).to eq(3)
184
+ end
185
+ end
186
+
187
+ shared_examples 'github appium shards' do |project_name, runner, shards|
188
+ subject(:pipeline) { read_generated(project_name, '.github/workflows/appium_pipeline.yml') }
189
+
190
+ let(:jobs) { YAML.safe_load(pipeline)['jobs'] }
191
+
192
+ def test_step(job, runner)
193
+ job['steps'].find { |step| step['run'].to_s.include?(runner) || step['with'].to_h['script'].to_s.include?(runner) }
194
+ end
195
+
196
+ if shards
197
+ it 'gives each platform job its own shard of two, so the gate waits for both' do
198
+ shards.each do |job_name, index|
199
+ expect(test_step(jobs[job_name], runner)['env'])
200
+ .to include('RAIDER_SHARD_INDEX' => index, 'RAIDER_SHARD_TOTAL' => '2')
201
+ end
202
+ end
203
+ else
204
+ it 'sets no shard env for a single platform' do
205
+ jobs.except('raider-comment', 'raider-gate').each_value do |job|
206
+ expect(test_step(job, runner)['env'].keys).not_to include('RAIDER_SHARD_INDEX', 'RAIDER_SHARD_TOTAL')
207
+ end
208
+ end
209
+ end
210
+ end
211
+
212
+ shared_examples 'github ffmpeg cache' do |project_name, file, test_jobs, runner = 'rspec'|
213
+ subject(:pipeline) { read_generated(project_name, ".github/workflows/#{file}") }
214
+
215
+ let(:jobs) { YAML.safe_load(pipeline)['jobs'] }
216
+
217
+ test_jobs.each do |job_name|
218
+ it "caches ~/.cache/raider keyed on the ffmpeg version and Gemfile.lock, then installs ffmpeg, before the tests in #{job_name}" do
219
+ steps = jobs[job_name]['steps']
220
+ cache = steps.index { |step| step['uses'] == 'actions/cache@v4' }
221
+ install = steps.index { |step| step['run'] == 'bundle exec raider u video install' }
222
+ tests = steps.index do |step|
223
+ step['run'].to_s.include?(runner) || step['with'].to_h['script'].to_s.include?(runner)
224
+ end
225
+ expect([cache, install, tests]).to all(be_an(Integer))
226
+ expect(cache).to be < install
227
+ expect(install).to be < tests
228
+ expect(steps[cache]['with']).to eq(
229
+ 'path' => '~/.cache/raider',
230
+ 'key' => "raider-ffmpeg-#{RubyRaider::Ffmpeg::MANIFEST[:version]}-${{ runner.os }}-${{ hashFiles('**/Gemfile.lock') }}"
231
+ )
232
+ end
233
+
234
+ it "keeps the ffmpeg install step from failing #{job_name}, since video is optional" do
235
+ install = jobs[job_name]['steps'].find { |step| step['run'] == 'bundle exec raider u video install' }
236
+ expect(install['continue-on-error']).to be(true)
237
+ end
238
+ end
239
+
240
+ it 'adds no cache or install step to the comment and gate jobs' do
241
+ jobs.slice('raider-comment', 'raider-gate').each_value do |job|
242
+ expect(job['steps'].map { |step| step['run'] || step['uses'] })
243
+ .not_to include('actions/cache@v4', 'bundle exec raider u video install')
244
+ end
245
+ end
246
+ end
247
+
154
248
  context 'with rspec and selenium' do
155
249
  name = "#{FrameworkIndex::RSPEC}_#{AutomationIndex::SELENIUM}"
250
+ include_examples 'github ffmpeg cache', name, 'test_pipeline.yml', %w[build]
156
251
  include_examples 'valid github pipeline', name
157
252
  include_examples 'github rspec pipeline', name
158
253
  include_examples 'github triggers', name, 'test_pipeline.yml'
159
254
  include_examples 'github raider wiring', name, 'test_pipeline.yml'
160
255
  include_examples 'github raider-comment job', name, 'test_pipeline.yml', %w[build]
256
+ include_examples 'github raider-gate job', name, 'test_pipeline.yml', %w[build]
161
257
  include_examples 'github without allure steps', name
162
258
  end
163
259
 
164
260
  context 'with cucumber and selenium' do
165
261
  name = "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::SELENIUM}"
262
+ include_examples 'github ffmpeg cache', name, 'test_pipeline.yml', %w[build], 'cucumber'
166
263
  include_examples 'valid github pipeline', name
167
264
  include_examples 'github cucumber pipeline', name
168
265
  include_examples 'github triggers', name, 'test_pipeline.yml'
169
266
  include_examples 'github raider wiring', name, 'test_pipeline.yml', 'cucumber'
170
267
  include_examples 'github raider-comment job', name, 'test_pipeline.yml', %w[build]
268
+ include_examples 'github raider-gate job', name, 'test_pipeline.yml', %w[build]
171
269
  include_examples 'github without allure steps', name
172
270
  end
173
271
 
174
272
  context 'with rspec and watir' do
175
273
  name = "#{FrameworkIndex::RSPEC}_#{AutomationIndex::WATIR}"
274
+ include_examples 'github ffmpeg cache', name, 'test_pipeline.yml', %w[build]
176
275
  include_examples 'valid github pipeline', name
177
276
  include_examples 'github rspec pipeline', name
178
277
  include_examples 'github triggers', name, 'test_pipeline.yml'
179
278
  include_examples 'github raider wiring', name, 'test_pipeline.yml'
180
279
  include_examples 'github raider-comment job', name, 'test_pipeline.yml', %w[build]
280
+ include_examples 'github raider-gate job', name, 'test_pipeline.yml', %w[build]
181
281
  include_examples 'github without allure steps', name
182
282
  end
183
283
 
184
284
  context 'with cucumber and watir' do
185
285
  name = "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::WATIR}"
286
+ include_examples 'github ffmpeg cache', name, 'test_pipeline.yml', %w[build], 'cucumber'
186
287
  include_examples 'valid github pipeline', name
187
288
  include_examples 'github cucumber pipeline', name
188
289
  include_examples 'github triggers', name, 'test_pipeline.yml'
189
290
  include_examples 'github raider wiring', name, 'test_pipeline.yml', 'cucumber'
190
291
  include_examples 'github raider-comment job', name, 'test_pipeline.yml', %w[build]
292
+ include_examples 'github raider-gate job', name, 'test_pipeline.yml', %w[build]
191
293
  include_examples 'github without allure steps', name
192
294
  end
193
295
 
194
296
  appium_jobs = { 'android' => %w[android-tests], 'ios' => %w[ios-tests],
195
297
  'cross_platform' => %w[ios-tests android-tests] }
196
298
 
299
+ appium_shards = { 'cross_platform' => { 'ios-tests' => '1', 'android-tests' => '2' } }
300
+
197
301
  appium_jobs.each do |automation, test_jobs|
198
302
  context "with rspec and #{automation}" do
199
303
  name = "#{FrameworkIndex::RSPEC}_#{automation}"
304
+ include_examples 'github appium shards', name, 'rspec', appium_shards[automation]
305
+ include_examples 'github ffmpeg cache', name, 'appium_pipeline.yml', test_jobs
200
306
  include_examples 'github raider-comment job', name, 'appium_pipeline.yml', test_jobs
307
+ include_examples 'github raider-gate job', name, 'appium_pipeline.yml', test_jobs
201
308
  include_examples 'github triggers', name, 'appium_pipeline.yml'
202
309
  include_examples 'github raider wiring', name, 'appium_pipeline.yml'
203
310
  end
204
311
 
205
312
  context "with cucumber and #{automation}" do
206
313
  name = "#{FrameworkIndex::CUCUMBER}_#{automation}"
314
+ include_examples 'github appium shards', name, 'cucumber', appium_shards[automation]
315
+ include_examples 'github ffmpeg cache', name, 'appium_pipeline.yml', test_jobs, 'cucumber'
207
316
  include_examples 'github triggers', name, 'appium_pipeline.yml'
208
317
  include_examples 'github raider wiring', name, 'appium_pipeline.yml', 'cucumber'
209
318
  include_examples 'github raider-comment job', name, 'appium_pipeline.yml', test_jobs
319
+ include_examples 'github raider-gate job', name, 'appium_pipeline.yml', test_jobs
210
320
  end
211
321
  end
212
322
  end
@@ -133,6 +133,10 @@ describe 'Common file content' do
133
133
  it 'includes raider-results' do
134
134
  expect(gitignore).to include('raider-results')
135
135
  end
136
+
137
+ it 'includes rerun.txt' do
138
+ expect(gitignore).to include('rerun.txt')
139
+ end
136
140
  end
137
141
 
138
142
  # --- Spec helper retry config (C4) ---
@@ -187,6 +191,10 @@ describe 'Common file content' do
187
191
  it 'includes --retry flag' do
188
192
  expect(cucumber_yml).to include('--retry')
189
193
  end
194
+
195
+ it 'writes failed scenarios to rerun.txt for Re-run Failed' do
196
+ expect(cucumber_yml).to include('--format rerun --out rerun.txt')
197
+ end
190
198
  end
191
199
 
192
200
  # --- RSpec contexts ---
@@ -51,12 +51,21 @@ describe 'Config file content' do
51
51
  end
52
52
  end
53
53
 
54
+ shared_examples 'video config' do |project_name|
55
+ subject(:config) { YAML.safe_load(read_generated(project_name, 'config/config.yml'), permitted_classes: [Symbol]) }
56
+
57
+ it 'has video enabled at 5 fps and 1280 px max width' do
58
+ expect(config['video']).to eq('enabled' => true, 'fps' => 5, 'max_width' => 1280)
59
+ end
60
+ end
61
+
54
62
  # --- Web config per automation ---
55
63
 
56
64
  context 'with rspec and selenium' do
57
65
  name = "#{FrameworkIndex::RSPEC}_#{AutomationIndex::SELENIUM}"
58
66
  include_examples 'valid web config', name
59
67
  include_examples 'selenium config with timeout', name
68
+ include_examples 'video config', name
60
69
  end
61
70
 
62
71
  context 'with rspec and watir' do
@@ -73,6 +82,11 @@ describe 'Config file content' do
73
82
 
74
83
  # --- Mobile config ---
75
84
 
85
+ # Single-platform Appium projects have no config.yml; VideoHelper falls back to its defaults.
86
+ context 'with rspec and cross platform' do
87
+ include_examples 'video config', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::CROSS_PLATFORM}"
88
+ end
89
+
76
90
  context 'with rspec and appium android' do
77
91
  subject(:caps) { read_generated("#{FrameworkIndex::RSPEC}_#{AutomationIndex::ANDROID}", 'config/capabilities.yml') }
78
92
 
@@ -36,7 +36,7 @@ describe 'Cucumber raider_reporter wiring' do
36
36
 
37
37
  it 'quits the driver in an ensure of the After block, without a redundant begin' do
38
38
  quit = %w[android ios cross_platform].include?(automation) ? 'driver.quit_driver' : quit_call
39
- expect(hook).to match(/^\s*After do \|scenario\|\n.*\n^ensure\n\s+#{Regexp.escape(quit)}\nend/m)
39
+ expect(hook).to match(/^\s*After do \|scenario\|\n.*\n^ensure\n.*\n\s+#{Regexp.escape(quit)}\nend/m)
40
40
  expect(hook).not_to match(/^\s+begin$/)
41
41
  end
42
42
 
@@ -47,6 +47,27 @@ describe 'Cucumber raider_reporter wiring' do
47
47
  expect(env.index(attach)).to be < env.index(/driver\.quit|browser\.quit/, env.index(attach))
48
48
  end
49
49
 
50
+ context 'with video recording' do
51
+ target = automation == 'watir' ? 'browser' : 'driver'
52
+ video_type = %w[selenium watir].include?(automation) ? ':webm' : ':mp4'
53
+
54
+ it 'requires video_helper' do
55
+ expect(env).to include("require_relative '../../helpers/video_helper'")
56
+ end
57
+
58
+ it 'starts the recorder in Before' do
59
+ expect(env[/^Before do.*?^end$/m]).to include("@video_recorder = VideoHelper.recorder_for(#{target})", '@video_recorder.start')
60
+ end
61
+
62
+ it 'stops with keep: scenario.failed? before quitting, attaches the path and cleans up in the ensure' do
63
+ expect(hook).to include('video_path = @video_recorder&.stop(keep: scenario.failed?)')
64
+ expect(hook).to include("RaiderReporter.attach('video', video_path, type: #{video_type}) if video_path")
65
+ expect(hook.index('@video_recorder&.stop')).to be < hook.index("RaiderReporter.attach('video'")
66
+ expect(hook.index("RaiderReporter.attach('video'")).to be < hook.index(/^ensure/)
67
+ expect(hook.index(/^ensure/)).to be < hook.index('@video_recorder&.cleanup')
68
+ end
69
+ end
70
+
50
71
  it 'does not generate the allure helper' do
51
72
  expect(File).not_to exist(File.join(project_name, 'helpers/allure_helper.rb'))
52
73
  end
@@ -127,7 +127,7 @@ describe 'Gemfile content' do
127
127
  let(:gemfile) { read_generated("#{FrameworkIndex::RSPEC}_#{automation}", 'Gemfile') }
128
128
 
129
129
  it 'includes raider_reporter' do
130
- expect(gemfile).to include("gem 'raider_reporter', '~> 0.8'")
130
+ expect(gemfile).to include("gem 'raider_reporter', '~> 0.10'")
131
131
  end
132
132
 
133
133
  it 'has no allure gems' do
@@ -143,7 +143,7 @@ describe 'Gemfile content' do
143
143
  let(:gemfile) { read_generated("#{FrameworkIndex::CUCUMBER}_#{automation}", 'Gemfile') }
144
144
 
145
145
  it 'includes raider_reporter' do
146
- expect(gemfile).to include("gem 'raider_reporter', '~> 0.8'")
146
+ expect(gemfile).to include("gem 'raider_reporter', '~> 0.10'")
147
147
  end
148
148
 
149
149
  it 'drops allure-cucumber' do
@@ -156,5 +156,4 @@ describe 'Gemfile content' do
156
156
  end
157
157
  end
158
158
  end
159
-
160
159
  end
@@ -139,6 +139,43 @@ describe 'Helper file content' do
139
139
  end
140
140
  end
141
141
 
142
+ shared_examples 'video spec helper wiring' do |project_name, recorder_target, video_type|
143
+ subject(:helper) { read_generated(project_name, 'helpers/spec_helper.rb') }
144
+
145
+ let(:before_hook) { helper[/config\.before\(:each\) do.*?^ end$/m] }
146
+ let(:after_hook) { helper[/config\.after\(:each\).*\z/m] }
147
+
148
+ it 'requires video_helper' do
149
+ expect(helper).to include("require_relative 'video_helper'")
150
+ end
151
+
152
+ it 'starts a recorder in before(:each), after the viewport resize or driver start' do
153
+ start = "@video_recorder = VideoHelper.recorder_for(#{recorder_target})"
154
+ expect(before_hook).to include(start, '@video_recorder.start')
155
+ setup = before_hook[/driver\.start_driver|\.window\.maximize/]
156
+ expect(before_hook.index(setup)).to be < before_hook.index(start)
157
+ expect(before_hook.index(start)).to be < before_hook.index('@video_recorder.start')
158
+ end
159
+
160
+ it 'attaches "video" only when example.exception && VideoHelper.final_attempt?(example)' do
161
+ expect(after_hook).to match(/example\.exception\s*(\?|&&)\s*VideoHelper\.final_attempt\?\(example\)/)
162
+ expect(after_hook).to include("RaiderReporter.attach('video', video_path, type: #{video_type}) if video_path")
163
+ end
164
+
165
+ it 'stops and attaches, then cleans up and quits the driver in the ensure, so the browser closes if an attach raises' do
166
+ order = [after_hook.index('@video_recorder&.stop(keep: keep_video)'), after_hook.index("RaiderReporter.attach('video'"),
167
+ after_hook.index("\n ensure\n"), after_hook.index('@video_recorder&.cleanup'),
168
+ after_hook.index(/driver\.quit|browser\.quit/)]
169
+ expect(order).to all(be_an(Integer)).and eq(order.compact.sort)
170
+ expect(after_hook.scan(/driver\.quit|browser\.quit/).size).to eq(1)
171
+ expect(after_hook[order[2]..]).to match(/\n @video_recorder&\.cleanup\n (driver|browser)\.quit(_driver)?\n end\n/)
172
+ end
173
+
174
+ it 'keeps the screenshot attachment' do
175
+ expect(after_hook).to include("RaiderReporter.attach('screenshot', screenshot_path, type: :png)")
176
+ end
177
+ end
178
+
142
179
  shared_examples 'selenium spec helper' do |project_name|
143
180
  subject(:helper) { read_generated(project_name, 'helpers/spec_helper.rb') }
144
181
 
@@ -171,6 +208,113 @@ describe 'Helper file content' do
171
208
  end
172
209
  end
173
210
 
211
+ # --- Video helper ---
212
+
213
+ shared_examples 'valid video helper' do |project_name|
214
+ subject(:helper) { read_generated(project_name, 'helpers/video_helper.rb') }
215
+
216
+ it 'has frozen_string_literal' do
217
+ expect(helper).to have_frozen_string_literal
218
+ end
219
+
220
+ it 'has valid Ruby syntax' do
221
+ expect(helper).to have_valid_ruby_syntax
222
+ end
223
+
224
+ it 'defines VideoHelper with enabled?, recorder_for and disable!' do
225
+ expect(helper).to match(/module VideoHelper/)
226
+ expect(helper).to match(/def enabled\?/)
227
+ expect(helper).to match(/def recorder_for\(/)
228
+ expect(helper).to match(/def disable!\(/)
229
+ end
230
+
231
+ it 'has no ScreenCaptureRecorder and no CdpRecorder' do
232
+ expect(helper).not_to include('ScreenCaptureRecorder')
233
+ expect(helper).not_to include('CdpRecorder')
234
+ end
235
+ end
236
+
237
+ shared_examples 'web video helper' do |project_name|
238
+ subject(:helper) { read_generated(project_name, 'helpers/video_helper.rb') }
239
+
240
+ it 'defines a FrameRecorder and a Selenium FrameListener' do
241
+ expect(helper).to include('class FrameRecorder')
242
+ expect(helper).to include('class FrameListener < Selenium::WebDriver::Support::AbstractEventListener')
243
+ end
244
+
245
+ it 'captures only after navigate, click and change_value_of' do
246
+ hooks = helper.scan(/def ((?:after|before)_\w+)/).flatten
247
+ expect(hooks).to contain_exactly('after_navigate_to', 'after_click', 'after_change_value_of')
248
+ end
249
+
250
+ it 'encodes WebM with libvpx' do
251
+ expect(helper).to include("'libvpx'", 'video.webm')
252
+ end
253
+
254
+ it 'loads ffmpeg lazily from the ruby_raider gem' do
255
+ expect(helper).to include("require 'ruby_raider/ffmpeg'")
256
+ expect(helper).not_to match(/^require 'ruby_raider/)
257
+ end
258
+
259
+ it 'has no AppiumRecorder' do
260
+ expect(helper).not_to include('AppiumRecorder')
261
+ end
262
+ end
263
+
264
+ shared_examples 'mobile video helper' do |project_name|
265
+ subject(:helper) { read_generated(project_name, 'helpers/video_helper.rb') }
266
+
267
+ it 'defines an AppiumRecorder writing mp4' do
268
+ expect(helper).to include('class AppiumRecorder', 'start_recording_screen', 'video.mp4')
269
+ end
270
+
271
+ it 'has no FrameRecorder or listener' do
272
+ expect(helper).not_to include('FrameRecorder')
273
+ expect(helper).not_to include('AbstractEventListener')
274
+ end
275
+
276
+ it 'does not provision ffmpeg on this machine (Appium records on its own host)' do
277
+ expect(helper).not_to include('ruby_raider/ffmpeg', 'Ffmpeg.ensure!', 'ffmpeg_path')
278
+ end
279
+ end
280
+
281
+ shared_examples 'video listener wiring' do |project_name, helper_file|
282
+ subject(:helper) { read_generated(project_name, "helpers/#{helper_file}") }
283
+
284
+ it 'requires video_helper' do
285
+ expect(helper).to include("require_relative 'video_helper'")
286
+ end
287
+
288
+ it 'passes VideoHelper.listener only when it is not nil' do
289
+ expect(helper).to include('VideoHelper.listener')
290
+ expect(helper).to match(/\[:listener\] = listener if listener/)
291
+ end
292
+ end
293
+
294
+ context 'with rspec and selenium video' do
295
+ name = "#{FrameworkIndex::RSPEC}_#{AutomationIndex::SELENIUM}"
296
+ it_behaves_like 'valid video helper', name
297
+ it_behaves_like 'web video helper', name
298
+ it_behaves_like 'video listener wiring', name, 'driver_helper.rb'
299
+ end
300
+
301
+ context 'with cucumber and watir video' do
302
+ name = "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::WATIR}"
303
+ it_behaves_like 'valid video helper', name
304
+ it_behaves_like 'web video helper', name
305
+ it_behaves_like 'video listener wiring', name, 'browser_helper.rb'
306
+ end
307
+
308
+ context 'with rspec and ios video' do
309
+ name = "#{FrameworkIndex::RSPEC}_#{AutomationIndex::IOS}"
310
+ it_behaves_like 'valid video helper', name
311
+ it_behaves_like 'mobile video helper', name
312
+
313
+ it 'does not wire a listener into the appium driver helper' do
314
+ expect(read_generated(name, 'helpers/driver_helper.rb')).not_to include('listener')
315
+ end
316
+ end
317
+
174
318
  # --- RSpec + automation contexts ---
175
319
 
176
320
  context 'with rspec and selenium' do
@@ -211,6 +355,8 @@ describe 'Helper file content' do
211
355
  'watir' => 'browser.screenshot.save(screenshot_path)'
212
356
  }.fetch(automation, 'driver.screenshot(screenshot_path)')
213
357
  it_behaves_like 'raider reporter spec helper', name, screenshot_call
358
+ it_behaves_like 'video spec helper wiring', name, automation == 'watir' ? 'browser' : 'driver',
359
+ %w[selenium watir].include?(automation) ? ':webm' : ':mp4'
214
360
  end
215
361
 
216
362
  context "with cucumber and #{automation}" do
@@ -166,5 +166,4 @@ describe 'Page object content' do
166
166
  include_examples 'valid abstract page', name
167
167
  include_examples 'watir abstract page', name
168
168
  end
169
-
170
169
  end