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.
Files changed (88) 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 +23 -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/feature.tt +5 -0
  15. data/lib/generators/cucumber/templates/partials/appium_env.tt +6 -0
  16. data/lib/generators/cucumber/templates/partials/selenium_env.tt +22 -0
  17. data/lib/generators/cucumber/templates/partials/watir_env.tt +22 -0
  18. data/lib/generators/cucumber/templates/partials/web_steps.tt +4 -0
  19. data/lib/generators/helper_generator.rb +12 -1
  20. data/lib/generators/infrastructure/azure_generator.rb +1 -0
  21. data/lib/generators/infrastructure/github_generator.rb +1 -0
  22. data/lib/generators/infrastructure/templates/azure_pipelines.tt +12 -1
  23. data/lib/generators/infrastructure/templates/github.tt +12 -0
  24. data/lib/generators/infrastructure/templates/github_appium.tt +23 -0
  25. data/lib/generators/invoke_generators.rb +20 -2
  26. data/lib/generators/rspec/rspec_generator.rb +0 -1
  27. data/lib/generators/rspec/templates/spec.tt +12 -0
  28. data/lib/generators/templates/common/gemfile.tt +1 -1
  29. data/lib/generators/templates/common/git_ignore.tt +7 -1
  30. data/lib/generators/templates/common/partials/mobile_config.tt +5 -0
  31. data/lib/generators/templates/common/partials/web_config.tt +9 -1
  32. data/lib/generators/templates/common/read_me.tt +84 -2
  33. data/lib/generators/templates/helpers/browser_helper.tt +5 -1
  34. data/lib/generators/templates/helpers/driver_helper.tt +1 -0
  35. data/lib/generators/templates/helpers/partials/selenium_driver.tt +4 -1
  36. data/lib/generators/templates/helpers/session_state.tt +206 -0
  37. data/lib/generators/templates/helpers/spec_helper.tt +28 -0
  38. data/lib/generators/templates/helpers/video_helper.tt +338 -171
  39. data/lib/ruby_raider/ffmpeg.rb +280 -0
  40. data/lib/ruby_raider.rb +2 -3
  41. data/lib/scaffolding/project_detector.rb +1 -3
  42. data/lib/scaffolding/scaffolding.rb +0 -1
  43. data/lib/utilities/utilities.rb +15 -2
  44. data/lib/version +1 -1
  45. data/ruby_raider.gemspec +4 -1
  46. data/sig/commands/utility_commands.rbs +4 -1
  47. data/sig/generators/invoke_generators.rbs +2 -3
  48. data/sig/ruby_raider/ffmpeg.rbs +23 -0
  49. data/sig/utilities/utilities.rbs +1 -0
  50. data/spec/commands/raider_commands_spec.rb +56 -1
  51. data/spec/generators/fixtures/video_helper_stubs/selenium-webdriver.rb +28 -0
  52. data/spec/generators/video_helper_spec.rb +608 -0
  53. data/spec/integration/commands/browser_update_after_creation_spec.rb +7 -6
  54. data/spec/integration/commands/utility_commands_spec.rb +2 -2
  55. data/spec/integration/content/azure_content_spec.rb +33 -0
  56. data/spec/integration/content/ci_content_spec.rb +54 -0
  57. data/spec/integration/content/common_content_spec.rb +35 -0
  58. data/spec/integration/content/config_content_spec.rb +32 -0
  59. data/spec/integration/content/cucumber_reporter_content_spec.rb +47 -1
  60. data/spec/integration/content/gemfile_content_spec.rb +2 -3
  61. data/spec/integration/content/helper_content_spec.rb +178 -0
  62. data/spec/integration/content/page_content_spec.rb +0 -1
  63. data/spec/integration/content/readme_content_spec.rb +79 -0
  64. data/spec/integration/content/rspec_reporter_content_spec.rb +2 -2
  65. data/spec/integration/content/syntax_validation_spec.rb +15 -0
  66. data/spec/integration/content/test_content_spec.rb +27 -3
  67. data/spec/integration/end_to_end_features_spec.rb +0 -2
  68. data/spec/integration/end_to_end_spec.rb +275 -26
  69. data/spec/integration/generators/azure_generator_spec.rb +7 -6
  70. data/spec/integration/generators/common_generator_spec.rb +1 -1
  71. data/spec/integration/generators/helpers_generator_spec.rb +22 -0
  72. data/spec/integration/generators/invoke_generators_spec.rb +52 -0
  73. data/spec/integration/helpers/session_state_spec.rb +428 -0
  74. data/spec/integration/scaffolding_e2e_spec.rb +13 -24
  75. data/spec/scaffolding/scaffold_project_detector_spec.rb +0 -1
  76. data/spec/scaffolding/scaffolding_features_spec.rb +0 -1
  77. data/spec/system/selenium_spec.rb +0 -1
  78. data/spec/system/watir_spec.rb +0 -1
  79. data/spec/utilities/ffmpeg_spec.rb +424 -0
  80. data/spec/utilities/utilities_spec.rb +19 -2
  81. data/spec/utilities/video_command_spec.rb +120 -0
  82. metadata +65 -11
  83. data/lib/commands/adopt_commands.rb +0 -42
  84. data/lib/commands/loaded_commands.rb +0 -9
  85. data/lib/commands/plugin_commands.rb +0 -32
  86. data/sig/commands/adopt_commands.rbs +0 -8
  87. data/sig/commands/loaded_commands.rbs +0 -5
  88. data/sig/commands/plugin_commands.rbs +0 -9
@@ -3,6 +3,57 @@
3
3
  require_relative 'content_helper'
4
4
 
5
5
  describe 'Generated Readme content' do
6
+ FRAMEWORKS.product(AUTOMATION_TYPES).each do |framework, automation|
7
+ context "with #{framework} and #{automation}, the Video replay section" do
8
+ subject(:section) { readme[/^### Video replay\n.*?(?=^##)/m]&.gsub(/\s+/, ' ') }
9
+
10
+ let(:readme) { read_generated("#{framework}_#{automation}", 'Readme.md') }
11
+ let(:web) { %w[selenium watir].include?(automation) }
12
+
13
+ it 'exists and covers the defaults, VIDEO=off, the install commands and the cache' do
14
+ expect(section).not_to be_nil
15
+ expect(section).to include('only for failing tests', 'VIDEO=off', 'raider u video install',
16
+ 'raider u video status', '~/.cache/raider', 'RAIDER_FFMPEG_PATH',
17
+ 'RAIDER_CACHE_DIR', 'raider-results/attachments/')
18
+ end
19
+
20
+ it 'says that video needs RAIDER_FFMPEG_PATH until the ffmpeg release is published' do
21
+ expect(section).to include('ffmpeg-7.1', 'not published yet')
22
+ end
23
+
24
+ it 'names the right video format and how to turn video off for this project' do
25
+ if web
26
+ expect(section).to include('video.webm', 'raider u video off', 'raider u video on', 'video:')
27
+ elsif %w[android ios].include?(automation)
28
+ expect(section).to include('video.mp4', 'raider u video off', 'creates `config/config.yml`')
29
+ expect(section).not_to include('no `config/config.yml`')
30
+ else
31
+ expect(section).to include('video.mp4', 'raider u video off')
32
+ end
33
+ end
34
+
35
+ it 'says where mobile recording needs ffmpeg and how long it records' do
36
+ limits = { 'ios' => ['1800 s'], 'android' => ['180 s'], 'cross_platform' => ['1800 s', '180 s'] }
37
+ if web
38
+ expect(section).not_to include('Appium server host')
39
+ else
40
+ expect(section).to include('`ffmpeg` on the Appium server host', 'iOS', 'longer than 180 s',
41
+ 'time_limit', *limits.fetch(automation))
42
+ expect(section).not_to include('do not use ffmpeg')
43
+ end
44
+ end
45
+
46
+ it 'lists the known limits of web recording' do
47
+ if web
48
+ expect(section).to include('after each page load, click and text change', 'next frame',
49
+ 'one recorded driver per process')
50
+ else
51
+ expect(section).not_to include('next frame')
52
+ end
53
+ end
54
+ end
55
+ end
56
+
6
57
  AUTOMATION_TYPES.each do |automation|
7
58
  context "with rspec and #{automation}" do
8
59
  subject(:readme) { read_generated("#{FrameworkIndex::RSPEC}_#{automation}", 'Readme.md') }
@@ -61,4 +112,32 @@ describe 'Generated Readme content' do
61
112
  end
62
113
  end
63
114
  end
115
+
116
+ FRAMEWORKS.product(AUTOMATION_TYPES).each do |framework, automation|
117
+ context "with #{framework} and #{automation}, the login reuse section" do
118
+ subject(:readme) { read_generated("#{framework}_#{automation}", 'Readme.md') }
119
+
120
+ if %w[selenium watir].include?(automation)
121
+ it 'documents the tags, other roles, max_age, the env switch and the secrets warning' do
122
+ section = readme[/^## Reusing the login\n.*?(?=^##)/m].gsub(/\s+/, ' ')
123
+ expect(section).to include('`:logged_in`', '`@logged_in`', "SessionState.new(driver, name: 'admin')",
124
+ 'browser.driver', 'login_state', 'max_age', 'RAIDER_LOGIN_STATE=off',
125
+ 'RAIDER_LOGIN_STATE=refresh', '.raider/auth/', 'git-ignored',
126
+ 'live session tokens', 'must not be committed, cached or uploaded')
127
+ end
128
+
129
+ it 'is its own ## section, straight after the Quality gate and before any CI section' do
130
+ heading = readme.index("\n## Reusing the login\n")
131
+ gate = readme.index("\n### Quality gate\n")
132
+ expect(gate).to be < heading
133
+ expect(readme[(gate + 1)...heading]).not_to match(/\n##/)
134
+ expect(readme[(heading + 1)..][/\n## .*/]).to be_nil.or match(/\A\n## (CI|Azure Pipelines)\z/)
135
+ end
136
+ else
137
+ it 'does not mention login reuse' do
138
+ expect(readme).not_to include('Reusing the login', 'RAIDER_LOGIN_STATE', '.raider/auth/')
139
+ end
140
+ end
141
+ end
142
+ end
64
143
  end
@@ -18,8 +18,8 @@ describe 'RSpec projects without Allure' do
18
18
  expect(File).not_to exist(File.join(project_name, 'allure-results'))
19
19
  end
20
20
 
21
- it 'pins raider_reporter 0.9' do
22
- expect(read_generated(project_name, 'Gemfile')).to include("gem 'raider_reporter', '~> 0.9'")
21
+ it 'pins raider_reporter 0.10' do
22
+ expect(read_generated(project_name, 'Gemfile')).to include("gem 'raider_reporter', '~> 0.10'")
23
23
  end
24
24
  end
25
25
  end
@@ -24,6 +24,21 @@ describe 'Ruby syntax validation' do
24
24
  expect(content).to have_frozen_string_literal
25
25
  end
26
26
  end
27
+
28
+ login_reuse_files = %w[helpers/session_state.rb]
29
+ login_reuse_files += if framework == 'rspec'
30
+ %w[helpers/spec_helper.rb]
31
+ else
32
+ %w[features/support/env.rb features/step_definitions/login_steps.rb]
33
+ end
34
+
35
+ login_reuse_files.each do |relative|
36
+ it "#{relative} is generated as valid Ruby with frozen_string_literal" do
37
+ content = File.read(File.join(project_name, relative))
38
+ expect(content).to have_valid_ruby_syntax
39
+ expect(content).to have_frozen_string_literal
40
+ end
41
+ end
27
42
  end
28
43
  end
29
44
  end
@@ -111,8 +111,29 @@ describe 'Test file content' do
111
111
  expect(config).to include('default:')
112
112
  end
113
113
 
114
- it 'uses the pretty formatter, with the reporter activated from env.rb' do
115
- expect(config.strip).to eq('default: --format pretty --publish-quiet --retry 0')
114
+ it 'uses the pretty and rerun formatters, with the reporter activated from env.rb' do
115
+ expect(config.strip).to eq('default: --format pretty --format rerun --out rerun.txt --publish-quiet --retry 0')
116
+ end
117
+ end
118
+
119
+ shared_examples 'logged in example' do |project_name|
120
+ it 'adds a :logged_in example that reuses the saved login and keeps the login example' do
121
+ spec = read_generated(project_name, 'spec/login_page_spec.rb')
122
+ expect(spec).to include("describe 'Account', :logged_in", 'opens the account page with the saved login',
123
+ "describe 'Login' do", 'can successfully log in')
124
+ end
125
+ end
126
+
127
+ shared_examples 'logged in scenario' do |project_name|
128
+ it 'adds a @logged_in scenario and keeps the login scenario' do
129
+ feature = read_generated(project_name, 'features/login.feature')
130
+ expect(feature).to include("@logged_in\nScenario: A logged-in user opens the account page",
131
+ "Given I'm a registered user\n", 'Scenario: A user can login')
132
+ end
133
+
134
+ it 'defines the step that picks the user without logging in' do
135
+ steps = read_generated(project_name, 'features/step_definitions/login_steps.rb')
136
+ expect(steps).to include(%q(Given("I'm a {user}") do |user|))
116
137
  end
117
138
  end
118
139
 
@@ -122,12 +143,14 @@ describe 'Test file content' do
122
143
  name = "#{FrameworkIndex::RSPEC}_#{AutomationIndex::SELENIUM}"
123
144
  include_examples 'valid rspec spec', name
124
145
  include_examples 'selenium rspec spec', name
146
+ include_examples 'logged in example', name
125
147
  end
126
148
 
127
149
  context 'with rspec and watir' do
128
150
  name = "#{FrameworkIndex::RSPEC}_#{AutomationIndex::WATIR}"
129
151
  include_examples 'valid rspec spec', name
130
152
  include_examples 'watir rspec spec', name
153
+ include_examples 'logged in example', name
131
154
  end
132
155
 
133
156
  # --- Cucumber contexts ---
@@ -138,6 +161,7 @@ describe 'Test file content' do
138
161
  include_examples 'valid cucumber env', name
139
162
  include_examples 'valid cucumber world', name
140
163
  include_examples 'valid cucumber config', name
164
+ include_examples 'logged in scenario', name
141
165
  end
142
166
 
143
167
  context 'with cucumber and watir' do
@@ -146,6 +170,6 @@ describe 'Test file content' do
146
170
  include_examples 'valid cucumber env', name
147
171
  include_examples 'valid cucumber world', name
148
172
  include_examples 'valid cucumber config', name
173
+ include_examples 'logged in scenario', name
149
174
  end
150
-
151
175
  end
@@ -242,7 +242,6 @@ describe 'End-to-End Feature Validation' do
242
242
  end
243
243
  end
244
244
  end
245
-
246
245
  end
247
246
 
248
247
  # --- Feature: GitHub Actions CI ---
@@ -264,5 +263,4 @@ describe 'End-to-End Feature Validation' do
264
263
  expect(content).to include('rspec')
265
264
  end
266
265
  end
267
-
268
266
  end
@@ -4,6 +4,8 @@ require_relative 'spec_helper'
4
4
  require 'timeout'
5
5
  require 'open3'
6
6
  require 'json'
7
+ require 'erb'
8
+ require 'tmpdir'
7
9
 
8
10
  # End-to-end integration tests that verify generated projects actually work
9
11
  # These tests:
@@ -15,7 +17,7 @@ require 'json'
15
17
  # This ensures the template system produces working, executable test frameworks
16
18
  describe 'End-to-End Project Generation and Execution' do
17
19
  # Helper to run commands in a generated project directory
18
- def run_in_project(project_name, command, timeout: 300)
20
+ def run_in_project(project_name, command, timeout: 300, env: {})
19
21
  result = {
20
22
  success: false,
21
23
  stdout: '',
@@ -26,7 +28,7 @@ describe 'End-to-End Project Generation and Execution' do
26
28
  Dir.chdir(project_name) do
27
29
  Bundler.with_unbundled_env do
28
30
  Timeout.timeout(timeout) do
29
- stdout, stderr, status = Open3.capture3(command)
31
+ stdout, stderr, status = Open3.capture3(env, command)
30
32
  result[:stdout] = stdout
31
33
  result[:stderr] = stderr
32
34
  result[:exit_code] = status.exitstatus
@@ -49,6 +51,15 @@ describe 'End-to-End Project Generation and Execution' do
49
51
  gemfile = File.join(project_name, 'Gemfile')
50
52
  root = File.expand_path('../..', __dir__)
51
53
  File.write(gemfile, File.read(gemfile).sub(/^gem 'ruby_raider'.*$/, "gem 'ruby_raider', path: '#{root}'"))
54
+ use_local_raider_reporter(gemfile)
55
+ end
56
+
57
+ # Set RAIDER_REPORTER_PATH to a raider_reporter checkout to resolve a release that is not on RubyGems yet.
58
+ def use_local_raider_reporter(gemfile)
59
+ path = ENV.fetch('RAIDER_REPORTER_PATH', nil)
60
+ return unless path
61
+
62
+ File.write(gemfile, File.read(gemfile).sub(/^gem 'raider_reporter'.*$/, "gem 'raider_reporter', path: '#{path}'"))
52
63
  end
53
64
 
54
65
  # Helper to install dependencies
@@ -104,10 +115,10 @@ describe 'End-to-End Project Generation and Execution' do
104
115
  expect(bundle_install(project)).to be true
105
116
  end
106
117
 
107
- it 'resolves raider_reporter 0.9 from RubyGems and ships the raider-reporter executable', :aggregate_failures do
118
+ it 'resolves raider_reporter 0.10+ from RubyGems and ships the raider-reporter executable', :aggregate_failures do
108
119
  skip 'Bundle install failed' unless bundle_install(project)
109
120
 
110
- expect(File.read(File.join(project, 'Gemfile.lock'))).to match(/^ raider_reporter \(0\.9\.\d+\)$/)
121
+ expect(File.read(File.join(project, 'Gemfile.lock'))).to match(/^ raider_reporter \(0\.1\d\.\d+\)$/)
111
122
  result = run_in_project(project, 'bundle exec raider-reporter --help', timeout: 60)
112
123
  expect(result[:exit_code]).to eq(0), "STDOUT: #{result[:stdout]}\nSTDERR: #{result[:stderr]}"
113
124
  expect(result[:stdout] + result[:stderr]).to include('raider-reporter comment')
@@ -163,11 +174,11 @@ describe 'End-to-End Project Generation and Execution' do
163
174
  "STDERR: #{result[:stderr]}"
164
175
  end
165
176
 
166
- it 'resolves raider_reporter 0.9 from RubyGems and requires its Cucumber plugin', :aggregate_failures do
177
+ it 'resolves raider_reporter 0.10+ from RubyGems and requires its Cucumber plugin', :aggregate_failures do
167
178
  skip 'Bundle install failed' unless bundle_install(project)
168
179
 
169
180
  lockfile = File.read(File.join(project, 'Gemfile.lock'))
170
- expect(lockfile).to match(/^ raider_reporter \(0\.9\.\d+\)$/)
181
+ expect(lockfile).to match(/^ raider_reporter \(0\.1\d\.\d+\)$/)
171
182
  expect(File.read(File.join(project, 'features/support/env.rb'))).to include("require 'raider_reporter/cucumber'")
172
183
  end
173
184
 
@@ -182,6 +193,17 @@ describe 'End-to-End Project Generation and Execution' do
182
193
  expect(result[:exit_code]).to eq(1)
183
194
  end
184
195
 
196
+ it 'writes rerun.txt listing only the failing scenario', :aggregate_failures, :slow do
197
+ skip 'Bundle install failed' unless bundle_install(project)
198
+
199
+ result = run_cucumber_with_failure(project)
200
+
201
+ expect(result[:exit_code]).to eq(1)
202
+ rerun = File.join(project, 'rerun.txt')
203
+ expect(File).to exist(rerun)
204
+ expect(File.read(rerun).strip).to eq('features/reporter_failure.feature:3')
205
+ end
206
+
185
207
  it 'writes raider-results/run.json listing every scenario with a failing scenario screenshot',
186
208
  :aggregate_failures, :slow do
187
209
  skip 'Bundle install failed' unless bundle_install(project)
@@ -211,7 +233,7 @@ describe 'End-to-End Project Generation and Execution' do
211
233
  # Runs the project's features plus a deliberately failing scenario, then removes that scenario again
212
234
  def run_cucumber_with_failure(project)
213
235
  add_failing_cucumber_scenario(project)
214
- FileUtils.rm_rf(File.join(project, 'raider-results'))
236
+ FileUtils.rm_rf([File.join(project, 'raider-results'), File.join(project, 'rerun.txt')])
215
237
  run_cucumber(project)
216
238
  ensure
217
239
  remove_failing_cucumber_scenario(project)
@@ -247,6 +269,29 @@ describe 'End-to-End Project Generation and Execution' do
247
269
  context 'Web Automation Frameworks' do
248
270
  describe 'Selenium + RSpec' do
249
271
  include_examples 'executable rspec project', 'rspec_selenium'
272
+
273
+ it 'saves the login once and reuses it', :aggregate_failures, :slow do
274
+ skip 'Bundle install failed' unless bundle_install('rspec_selenium')
275
+
276
+ state_file = File.join('rspec_selenium', '.raider', 'auth', 'user.json')
277
+ FileUtils.rm_rf(File.join('rspec_selenium', '.raider'))
278
+ first = run_in_project('rspec_selenium', 'bundle exec rspec spec/', env: { 'HEADLESS' => 'true' })
279
+ failed = ->(run, index) { "Run #{index} failed.\nSTDOUT: #{run[:stdout]}\nSTDERR: #{run[:stderr]}" }
280
+ raise failed.call(first, 1) unless first[:exit_code].zero? # show run 1's output, not a missing state file
281
+
282
+ expect(File).to exist(state_file)
283
+ saved = JSON.parse(File.read(state_file))
284
+ second = run_in_project('rspec_selenium', 'bundle exec rspec spec/', env: { 'HEADLESS' => 'true' })
285
+ expect(second[:exit_code]).to eq(0), failed.call(second, 2)
286
+ expect(File.stat(state_file).mode & 0o777).to eq(0o600)
287
+ expect(saved).to include('format' => 1, 'origin' => 'https://raider-test-site.onrender.com')
288
+ expect(JSON.parse(File.read(state_file))['saved_at']).to eq(saved['saved_at'])
289
+ cookie_values = saved['cookies'].map { |cookie| cookie['value'].to_s }
290
+ expect(cookie_values.reject(&:empty?)).not_to be_empty
291
+ output = [first, second].map { |run| run[:stdout] + run[:stderr] }.join
292
+ # Short values like "1" or "en" can appear in any output; a leaked token is long.
293
+ cookie_values.select { |value| value.length >= 8 }.each { |value| expect(output).not_to include(value) }
294
+ end
250
295
  end
251
296
 
252
297
  describe 'Watir + RSpec' do
@@ -263,20 +308,22 @@ describe 'End-to-End Project Generation and Execution' do
263
308
  end
264
309
 
265
310
  # Recorded Azure Pipelines env of a push (IndividualCI) build, see raider_reporter's CiDetector specs
266
- AZURE_PUSH_ENV = {
267
- 'TF_BUILD' => 'True',
268
- 'SYSTEM_COLLECTIONURI' => 'https://dev.azure.com/raider-test/',
269
- 'SYSTEM_TEAMPROJECT' => 'My Project',
270
- 'SYSTEM_TEAMPROJECTID' => '6ce954b1-ce1f-45d1-b94d-e6bf2464ba2c',
271
- 'BUILD_REPOSITORY_NAME' => 'web tests',
272
- 'BUILD_REPOSITORY_ID' => '1c2bd1a0-3f63-4d93-9c3c-2b4f2b1d8f11',
273
- 'BUILD_REPOSITORY_PROVIDER' => 'TfsGit',
274
- 'BUILD_BUILDID' => '42',
275
- 'SYSTEM_JOBDISPLAYNAME' => 'tests',
276
- 'BUILD_REASON' => 'IndividualCI',
277
- 'BUILD_SOURCEBRANCH' => 'refs/heads/release/1.2',
278
- 'BUILD_SOURCEVERSION' => 'abc'
279
- }.freeze
311
+ def azure_push_env
312
+ {
313
+ 'TF_BUILD' => 'True',
314
+ 'SYSTEM_COLLECTIONURI' => 'https://dev.azure.com/raider-test/',
315
+ 'SYSTEM_TEAMPROJECT' => 'My Project',
316
+ 'SYSTEM_TEAMPROJECTID' => '6ce954b1-ce1f-45d1-b94d-e6bf2464ba2c',
317
+ 'BUILD_REPOSITORY_NAME' => 'web tests',
318
+ 'BUILD_REPOSITORY_ID' => '1c2bd1a0-3f63-4d93-9c3c-2b4f2b1d8f11',
319
+ 'BUILD_REPOSITORY_PROVIDER' => 'TfsGit',
320
+ 'BUILD_BUILDID' => '42',
321
+ 'SYSTEM_JOBDISPLAYNAME' => 'tests',
322
+ 'BUILD_REASON' => 'IndividualCI',
323
+ 'BUILD_SOURCEBRANCH' => 'refs/heads/release/1.2',
324
+ 'BUILD_SOURCEVERSION' => 'abc'
325
+ }.freeze
326
+ end
280
327
 
281
328
  # Generates a ci: azure web project (the shared before(:all) only builds the default github ones)
282
329
  def generate_azure_project(framework)
@@ -289,7 +336,7 @@ describe 'End-to-End Project Generation and Execution' do
289
336
  end
290
337
 
291
338
  def azure_env_prefix
292
- "GITHUB_ACTIONS= HEADLESS=true #{AZURE_PUSH_ENV.map { |k, v| "#{k}='#{v}'" }.join(' ')}"
339
+ "GITHUB_ACTIONS= HEADLESS=true #{azure_push_env.map { |k, v| "#{k}='#{v}'" }.join(' ')}"
293
340
  end
294
341
 
295
342
  def expect_azure_ci_block(project)
@@ -310,12 +357,12 @@ describe 'End-to-End Project Generation and Execution' do
310
357
  describe 'Selenium + RSpec' do
311
358
  let(:project) { generate_azure_project('rspec') }
312
359
 
313
- it 'ships azure-pipelines.yml and resolves raider_reporter 0.9', :aggregate_failures do
360
+ it 'ships azure-pipelines.yml and resolves raider_reporter 0.10+', :aggregate_failures do
314
361
  expect(File).to exist(File.join(project, 'azure-pipelines.yml'))
315
362
  expect(File).not_to exist(File.join(project, '.github/workflows'))
316
363
  skip 'Bundle install failed' unless bundle_install(project)
317
364
 
318
- expect(File.read(File.join(project, 'Gemfile.lock'))).to match(/^ raider_reporter \(0\.9\.\d+\)$/)
365
+ expect(File.read(File.join(project, 'Gemfile.lock'))).to match(/^ raider_reporter \(0\.1\d\.\d+\)$/)
319
366
  end
320
367
 
321
368
  it 'records provider azure_pipelines and an azure_pipelines: build key in run.json', :aggregate_failures, :slow do
@@ -332,11 +379,11 @@ describe 'End-to-End Project Generation and Execution' do
332
379
  describe 'Selenium + Cucumber' do
333
380
  let(:project) { generate_azure_project('cucumber') }
334
381
 
335
- it 'ships azure-pipelines.yml and resolves raider_reporter 0.9', :aggregate_failures do
382
+ it 'ships azure-pipelines.yml and resolves raider_reporter 0.10+', :aggregate_failures do
336
383
  expect(File).to exist(File.join(project, 'azure-pipelines.yml'))
337
384
  skip 'Bundle install failed' unless bundle_install(project)
338
385
 
339
- expect(File.read(File.join(project, 'Gemfile.lock'))).to match(/^ raider_reporter \(0\.9\.\d+\)$/)
386
+ expect(File.read(File.join(project, 'Gemfile.lock'))).to match(/^ raider_reporter \(0\.1\d\.\d+\)$/)
340
387
  end
341
388
 
342
389
  it 'records provider azure_pipelines and an azure_pipelines: build key in run.json', :aggregate_failures, :slow do
@@ -351,6 +398,208 @@ describe 'End-to-End Project Generation and Execution' do
351
398
  end
352
399
  end
353
400
 
401
+ # Failure video replay against real browsers. Needs an ffmpeg with libvpx (RAIDER_FFMPEG_PATH), because the
402
+ # RaiderHQ ffmpeg release has no checksums yet. Skipped when it, or the browser, is missing. Firefox can be
403
+ # pointed at a specific binary with FIREFOX_BINARY.
404
+ context 'Failure video replay', :slow, :video do
405
+ let(:ffmpeg_path) { ENV.fetch('RAIDER_FFMPEG_PATH', '') }
406
+ let(:cache_dir) { Dir.mktmpdir('raider-cache') }
407
+
408
+ after { FileUtils.rm_rf(cache_dir) }
409
+
410
+ def skip_unless_video_prerequisites(browser)
411
+ skip 'RAIDER_FFMPEG_PATH is not set to an executable ffmpeg with libvpx' unless
412
+ File.file?(ffmpeg_path) && File.executable?(ffmpeg_path)
413
+ skip "#{browser} is not installed" unless browser_installed?(browser)
414
+ end
415
+
416
+ def browser_installed?(browser)
417
+ if browser == 'firefox'
418
+ ENV['FIREFOX_BINARY'] ? File.executable?(ENV['FIREFOX_BINARY']) : app_or_command?('Firefox', 'firefox')
419
+ else
420
+ app_or_command?('Google Chrome', 'google-chrome') || app_or_command?('Chromium', 'chromium')
421
+ end
422
+ end
423
+
424
+ def app_or_command?(app, command)
425
+ File.directory?("/Applications/#{app}.app") || system("command -v #{command} > /dev/null 2>&1")
426
+ end
427
+
428
+ # A separate project, so the default projects keep resolving raider_reporter 0.10+ from RubyGems
429
+ def generate_video_project(framework, automation)
430
+ name = "video_#{framework}_#{automation}"
431
+ unless LazyProjectGenerator::GENERATED.include?(name)
432
+ generate_framework(framework:, automation:, name:)
433
+ LazyProjectGenerator::GENERATED.add(name)
434
+ use_local_ruby_raider(name)
435
+ expect(run_in_project(name, 'bundle install --quiet', timeout: 300)[:success]).to be(true)
436
+ end
437
+ FileUtils.rm_rf(File.join(name, 'raider-results'))
438
+ name
439
+ end
440
+
441
+ def video_env(overrides = {})
442
+ { 'HEADLESS' => 'true', 'RAIDER_FFMPEG_PATH' => ffmpeg_path, 'RAIDER_CACHE_DIR' => cache_dir,
443
+ 'RAIDER_TOKEN' => nil, 'RAIDER_ENDPOINT' => nil, 'VIDEO' => nil }.merge(overrides)
444
+ end
445
+
446
+ def page_url
447
+ "data:text/html,#{ERB::Util.url_encode('<h1 id="title">Video</h1><input id="name"><button id="go">Go</button>')}"
448
+ end
449
+
450
+ def add_rspec_video_spec(project)
451
+ File.write(File.join(project, 'spec/video_replay_spec.rb'), <<~SPEC)
452
+ # frozen_string_literal: true
453
+
454
+ require_relative '../helpers/spec_helper'
455
+
456
+ describe 'Video replay' do
457
+ it 'passes after driving the page' do
458
+ driver.navigate.to('#{page_url}')
459
+ driver.find_element(id: 'name').send_keys('ok')
460
+ expect(driver.find_element(id: 'title').text).to eq('Video')
461
+ end
462
+
463
+ it 'fails after driving the page' do
464
+ driver.navigate.to('#{page_url}')
465
+ driver.find_element(id: 'name').send_keys('broken')
466
+ driver.find_element(id: 'go').click
467
+ expect(driver.find_element(id: 'title').text).to eq('deliberate video failure')
468
+ end
469
+ end
470
+ SPEC
471
+ end
472
+
473
+ def add_cucumber_video_feature(project, firefox_binary)
474
+ File.write(File.join(project, 'features/video_replay.feature'), <<~FEATURE)
475
+ Feature: Video replay
476
+
477
+ Scenario: A passing scenario
478
+ Given the video page is open
479
+ Then the video page title is "Video"
480
+
481
+ Scenario: A failing scenario
482
+ Given the video page is open
483
+ Then the video page title is "deliberate video failure"
484
+ FEATURE
485
+ File.write(File.join(project, 'features/step_definitions/video_replay_steps.rb'), <<~STEPS)
486
+ # frozen_string_literal: true
487
+
488
+ Given('the video page is open') do
489
+ browser.goto('#{page_url}')
490
+ browser.text_field(id: 'name').set('typed')
491
+ browser.button(id: 'go').click
492
+ end
493
+
494
+ Then('the video page title is {string}') do |title|
495
+ raise "expected title \#{title}" unless browser.h1(id: 'title').text == title
496
+ end
497
+ STEPS
498
+ config = File.join(project, 'config/config.yml')
499
+ File.write(config, File.read(config).sub(/^browser: .*$/, 'browser: firefox'))
500
+ # Test setup only: the generated templates do not read FIREFOX_BINARY.
501
+ File.write(File.join(project, 'features/support/zz_firefox_binary.rb'), <<~RUBY) if firefox_binary
502
+ # frozen_string_literal: true
503
+
504
+ Selenium::WebDriver::Firefox.path = ENV['FIREFOX_BINARY'] if ENV['FIREFOX_BINARY']
505
+ RUBY
506
+ end
507
+
508
+ def results(project)
509
+ JSON.parse(File.read(File.join(project, 'raider-results', 'run.json')))['results']
510
+ end
511
+
512
+ def video_files(project)
513
+ Dir.glob(File.join(project, 'raider-results', '**', '*.{webm,mp4}'))
514
+ end
515
+
516
+ def video_attachments(result)
517
+ result['attachments'].select { |attachment| attachment['name'] == 'video' }
518
+ end
519
+
520
+ def expect_video_for_failure_only(project, failing_name, run)
521
+ by_status = results(project).group_by { |r| r['status'] }
522
+ expect(by_status.keys).to match_array(%w[passed failed]), "STDOUT: #{run[:stdout]}\nSTDERR: #{run[:stderr]}"
523
+ failing = by_status['failed'].find { |r| r['full_name'].include?(failing_name) }
524
+ expect(failing).not_to be_nil, "No failing result named #{failing_name}"
525
+ expect(video_attachments(failing).size).to eq(1)
526
+ expect(video_attachments(failing).first['content_type']).to eq('video/webm')
527
+ by_status.fetch('passed', []).each { |r| expect(video_attachments(r)).to be_empty }
528
+ videos = video_files(project)
529
+ expect(videos.size).to eq(1)
530
+ expect(videos.first).to end_with('video.webm')
531
+ expect(File.size(videos.first)).to be_positive
532
+ expect(File.read(File.join(project, 'raider-results', 'index.html'))).to include('<video')
533
+ end
534
+
535
+ describe 'Selenium + RSpec on headless Chrome' do
536
+ let(:project) { 'video_rspec_selenium' }
537
+ let(:command) { 'bundle exec rspec spec/video_replay_spec.rb' }
538
+
539
+ before do
540
+ skip_unless_video_prerequisites('chrome')
541
+ generate_video_project('rspec', 'selenium')
542
+ add_rspec_video_spec(project)
543
+ end
544
+
545
+ after { FileUtils.rm_f(File.join(project, 'spec/video_replay_spec.rb')) }
546
+
547
+ it 'attaches video.webm to the failing example only and plays it in index.html', :aggregate_failures do
548
+ result = run_in_project(project, command, env: video_env)
549
+ expect(result[:exit_code]).to eq(1), "STDOUT: #{result[:stdout]}\nSTDERR: #{result[:stderr]}"
550
+ expect(result[:stderr]).not_to include('[video]')
551
+ expect_video_for_failure_only(project, 'fails after driving the page', result)
552
+ end
553
+
554
+ it 'writes no video with VIDEO=off', :aggregate_failures do
555
+ result = run_in_project(project, command, env: video_env('VIDEO' => 'off'))
556
+ expect(result[:exit_code]).to eq(1), "STDOUT: #{result[:stdout]}\nSTDERR: #{result[:stderr]}"
557
+ expect(video_files(project)).to be_empty
558
+ expect(results(project).flat_map { |r| video_attachments(r) }).to be_empty
559
+ expect(result[:stderr]).not_to include('[video]')
560
+ end
561
+
562
+ it 'runs unchanged with one [video] line and no download when no ffmpeg is usable', :aggregate_failures do
563
+ off = run_in_project(project, command, env: video_env('VIDEO' => 'off'))
564
+ FileUtils.rm_rf(File.join(project, 'raider-results'))
565
+ result = run_in_project(project, command, env: video_env('RAIDER_FFMPEG_PATH' => nil))
566
+
567
+ expect(result[:exit_code]).to eq(off[:exit_code]), "STDOUT: #{result[:stdout]}\nSTDERR: #{result[:stderr]}"
568
+ video_lines = result[:stderr].lines.grep(/\[video\]/)
569
+ expect(video_lines.size).to eq(1), "STDERR: #{result[:stderr]}"
570
+ # Decided at the first driver/recorder call, before any capture or fetch: the placeholder
571
+ # manifest has no checksum, so nothing is captured or downloaded.
572
+ expect(video_lines.first).to include('no ffmpeg available for this platform yet')
573
+ expect(video_files(project)).to be_empty
574
+ expect(Dir.glob(File.join(cache_dir, '**', '*'))).to be_empty
575
+ end
576
+ end
577
+
578
+ describe 'Watir + Cucumber on headless Firefox' do
579
+ let(:project) { 'video_cucumber_watir' }
580
+ let(:firefox_binary) { ENV.fetch('FIREFOX_BINARY', nil) }
581
+
582
+ before do
583
+ skip_unless_video_prerequisites('firefox')
584
+ generate_video_project('cucumber', 'watir')
585
+ add_cucumber_video_feature(project, firefox_binary)
586
+ end
587
+
588
+ after do
589
+ FileUtils.rm_f(File.join(project, 'features/video_replay.feature'))
590
+ FileUtils.rm_f(File.join(project, 'features/step_definitions/video_replay_steps.rb'))
591
+ end
592
+
593
+ it 'attaches video.webm to the failing scenario only and plays it in index.html', :aggregate_failures do
594
+ result = run_in_project(project, 'bundle exec cucumber features/video_replay.feature',
595
+ env: video_env('FIREFOX_BINARY' => firefox_binary), timeout: 240)
596
+ expect(result[:exit_code]).to eq(1), "STDOUT: #{result[:stdout]}\nSTDERR: #{result[:stderr]}"
597
+ expect(result[:stderr]).not_to include('[video]')
598
+ expect_video_for_failure_only(project, 'A failing scenario', result)
599
+ end
600
+ end
601
+ end
602
+
354
603
  # Mobile and Visual frameworks require external services, so we only verify structure
355
604
  context 'Mobile Automation Frameworks (structure validation only)' do
356
605
  describe 'iOS + RSpec' do
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'fileutils'
4
+ require 'open3'
4
5
  require 'yaml'
5
6
  require_relative '../../../lib/generators/infrastructure/azure_generator'
6
7
  require_relative '../spec_helper'
@@ -63,10 +64,10 @@ describe AzureGenerator do
63
64
  end
64
65
 
65
66
  context 'with an invalid ci' do
66
- it 'raises an ArgumentError and generates nothing' do
67
+ it 'raises a Thor::Error and generates nothing' do
67
68
  expect do
68
69
  InvokeGenerators.generate_framework(automation: 'selenium', framework: 'rspec', name: 'azure_bad', ci: 'gitlab')
69
- end.to raise_error(ArgumentError, 'ci must be github or azure')
70
+ end.to raise_error(Thor::Error, 'ci must be github or azure')
70
71
  expect(File).not_to exist('azure_bad')
71
72
  end
72
73
  end
@@ -74,10 +75,10 @@ describe AzureGenerator do
74
75
  context 'with raider new and ci:gitlab' do
75
76
  it 'prints the error, generates nothing and exits non-zero' do
76
77
  root = File.expand_path('../../..', __dir__)
77
- out = IO.popen(['ruby', "#{root}/bin/raider", 'new', 'azure_cli_bad', '-p', 'framework:rspec',
78
- 'automation:selenium', 'ci:gitlab', { err: %i[child out] }], &:read)
79
- expect($CHILD_STATUS.exitstatus).not_to eq(0)
80
- expect(out).to include('Invalid parameters: ci must be github or azure')
78
+ out, status = Open3.capture2e('ruby', "#{root}/bin/raider", 'new', 'azure_cli_bad', '-p', 'framework:rspec',
79
+ 'automation:selenium', 'ci:gitlab')
80
+ expect(status.exitstatus).not_to eq(0)
81
+ expect(out).to include('ci must be github or azure')
81
82
  expect(File).not_to exist('azure_cli_bad')
82
83
  end
83
84
  end
@@ -18,7 +18,7 @@ describe CommonGenerator do
18
18
  end
19
19
  end
20
20
 
21
- shared_examples 'creates a config file' do |name|
21
+ shared_examples 'creates a config file' do |name|
22
22
  it 'creates a config file' do
23
23
  expect(File).to exist("#{name}/config/config.yml")
24
24
  end