ruby_raider 3.4.0 → 3.6.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +12 -2
  3. data/RELEASE.md +18 -0
  4. data/lib/generators/common_generator.rb +2 -0
  5. data/lib/generators/cucumber/templates/cucumber.tt +1 -1
  6. data/lib/generators/cucumber/templates/partials/appium_env.tt +10 -5
  7. data/lib/generators/cucumber/templates/partials/selenium_env.tt +9 -5
  8. data/lib/generators/cucumber/templates/partials/watir_env.tt +9 -5
  9. data/lib/generators/generator.rb +9 -0
  10. data/lib/generators/helper_generator.rb +2 -0
  11. data/lib/generators/infrastructure/azure_generator.rb +11 -0
  12. data/lib/generators/infrastructure/github_generator.rb +2 -2
  13. data/lib/generators/infrastructure/templates/azure_pipelines.tt +44 -0
  14. data/lib/generators/infrastructure/templates/github.tt +4 -7
  15. data/lib/generators/infrastructure/templates/github_appium.tt +0 -6
  16. data/lib/generators/invoke_generators.rb +19 -1
  17. data/lib/generators/menu_generator.rb +16 -6
  18. data/lib/generators/templates/common/gemfile.tt +2 -4
  19. data/lib/generators/templates/common/git_ignore.tt +3 -1
  20. data/lib/generators/templates/common/read_me.tt +27 -1
  21. data/lib/ruby_raider.rb +8 -1
  22. data/lib/version +1 -1
  23. data/sig/generators/generator.rbs +2 -0
  24. data/sig/generators/infrastructure/azure_generator.rbs +4 -0
  25. data/sig/generators/invoke_generators.rbs +5 -0
  26. data/sig/generators/menu_generator.rbs +2 -1
  27. data/sig/ruby_raider.rbs +1 -0
  28. data/spec/integration/content/azure_content_spec.rb +109 -0
  29. data/spec/integration/content/ci_content_spec.rb +24 -24
  30. data/spec/integration/content/common_content_spec.rb +4 -4
  31. data/spec/integration/content/cucumber_reporter_content_spec.rb +64 -0
  32. data/spec/integration/content/gemfile_content_spec.rb +7 -7
  33. data/spec/integration/content/helper_content_spec.rb +2 -6
  34. data/spec/integration/content/readme_content_spec.rb +13 -3
  35. data/spec/integration/content/test_content_spec.rb +4 -0
  36. data/spec/integration/end_to_end_spec.rb +171 -3
  37. data/spec/integration/generators/azure_generator_spec.rb +100 -0
  38. data/spec/integration/generators/helpers_generator_spec.rb +6 -3
  39. data/spec/menus/menu_generator_spec.rb +49 -0
  40. metadata +8 -2
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'fileutils'
5
+ require_relative 'content_helper'
6
+
7
+ # UseRubyVersion@0 versionSpec '3.4' resolves against the hosted tool cache, which lists Ruby 3.4.x
8
+ # (3.2, 3.3, 3.4, 4.0): https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md
9
+ describe 'Azure Pipelines content' do
10
+ combos = FRAMEWORKS.product(%w[selenium watir], [false, true])
11
+
12
+ before(:all) do # rubocop:disable RSpec/BeforeAfterAll
13
+ combos.each do |framework, automation, axe|
14
+ name = "azurec_#{framework}_#{automation}#{'_axe' if axe}"
15
+ next if File.exist?(name)
16
+
17
+ InvokeGenerators.generate_framework(automation:, framework:, name:, accessibility: axe, ci: 'azure')
18
+ end
19
+ end
20
+
21
+ after(:all) { FileUtils.rm_rf(Dir.glob('azurec_*')) } # rubocop:disable RSpec/BeforeAfterAll
22
+
23
+ combos.each do |framework, automation, axe|
24
+ context "with #{framework}, #{automation}#{', axe' if axe}" do
25
+ let(:name) { "azurec_#{framework}_#{automation}#{'_axe' if axe}" }
26
+ let(:raw) { read_generated(name, 'azure-pipelines.yml') }
27
+ let(:yml) { YAML.safe_load(raw) }
28
+ let(:run_command) do
29
+ if framework == 'cucumber'
30
+ 'bundle exec cucumber features/ --format pretty'
31
+ else
32
+ 'bundle exec rspec spec/ --format documentation'
33
+ end
34
+ end
35
+
36
+ it 'has a tests job with ruby, bundle install, run command and an always-published artifact' do
37
+ tests = yml['jobs'][0]
38
+ expect(tests['job']).to eq('tests')
39
+ steps = tests['steps']
40
+ expect(steps.first).to eq('task' => 'UseRubyVersion@0', 'inputs' => { 'versionSpec' => '3.4' })
41
+ expect(steps.map { |s| s['script'] }).to include('bundle install', run_command)
42
+ publish = steps.find { |s| s['task'] == 'PublishPipelineArtifact@1' }
43
+ expect(publish['condition']).to eq('always()')
44
+ expect(publish['inputs']).to eq(
45
+ 'targetPath' => 'raider-results',
46
+ 'artifact' => 'raider-results-$(System.JobPositionInPhase)-$(System.JobAttempt)'
47
+ )
48
+ end
49
+
50
+ it 'passes exactly the Raider settings to the test step' do
51
+ step = yml['jobs'][0]['steps'].find { |s| s['script'] == run_command }
52
+ expect(step['env']).to eq('RAIDER_TOKEN' => '$(RAIDER_TOKEN)', 'RAIDER_ENDPOINT' => '$(RAIDER_ENDPOINT)')
53
+ expect(yml['variables']['HEADLESS']).to eq('true')
54
+ end
55
+
56
+ it 'has a raider_comment job for pull requests' do
57
+ comment = yml['jobs'][1]
58
+ expect(comment['job']).to eq('raider_comment')
59
+ expect(comment['dependsOn']).to eq('tests')
60
+ expect(comment['condition']).to eq("and(always(), eq(variables['Build.Reason'], 'PullRequest'))")
61
+ last = comment['steps'].last
62
+ expect(last['script']).to eq('bundle exec raider-reporter comment')
63
+ expect(last['env']).to include('SYSTEM_ACCESSTOKEN' => '$(System.AccessToken)')
64
+ end
65
+
66
+ it 'has triggers, pool and the self-hosted hint' do
67
+ expect(yml['trigger']['branches']['include']).to include('main', 'master')
68
+ expect(yml['pr']['branches']['include']).to include('*')
69
+ expect(yml['pool']).to eq('vmImage' => 'ubuntu-latest')
70
+ expect(raw).to include('# Self-hosted agent? Replace vmImage with: pool: { name: Default }')
71
+ end
72
+
73
+ it 'explains that Azure Repos ignores pr: directly above the pr block' do
74
+ expect(raw).to include('# Azure Repos ignores pr: — add this pipeline as a Build validation branch policy ' \
75
+ "so pull request builds (and the Raider comment) run.\npr:")
76
+ end
77
+
78
+ it 'has no Allure' do
79
+ expect(raw).not_to match(/allure/i)
80
+ end
81
+
82
+ it 'documents Azure Pipelines in the README and no GitHub Actions setup' do
83
+ readme = read_generated(name, 'Readme.md')
84
+ expect(readme).to include('## Azure Pipelines', 'RAIDER_TOKEN', 'Contribute to pull requests',
85
+ 'pool: { name: Default }', 'Build validation branch policy',
86
+ 'same stage')
87
+ expect(readme).not_to match(/GitHub Actions/i)
88
+ end
89
+ end
90
+ end
91
+
92
+ context 'with a mobile project and ci azure' do
93
+ let(:readme) { read_generated('azurec_mobile', 'Readme.md') }
94
+
95
+ before(:all) do # rubocop:disable RSpec/BeforeAfterAll
96
+ InvokeGenerators.generate_framework(automation: 'ios', framework: 'rspec', name: 'azurec_mobile', ci: 'azure')
97
+ end
98
+
99
+ it 'says the template is not available and does not mention azure-pipelines.yml' do
100
+ expect(readme).to include('## CI', 'Azure Pipelines template for mobile projects is not available yet. ' \
101
+ 'Use GitHub Actions (`ci:github`)')
102
+ expect(readme).not_to include('azure-pipelines.yml', '## Azure Pipelines')
103
+ end
104
+ end
105
+
106
+ it 'does not generate azure-pipelines.yml for default projects' do
107
+ expect(File).not_to exist("#{project('rspec', 'selenium')}/azure-pipelines.yml")
108
+ end
109
+ end
@@ -62,13 +62,13 @@ describe 'CI pipeline content' do
62
62
  end
63
63
  end
64
64
 
65
- shared_examples 'github raider wiring' do |project_name, file|
65
+ shared_examples 'github raider wiring' do |project_name, file, runner = 'rspec'|
66
66
  subject(:pipeline) { read_generated(project_name, ".github/workflows/#{file}") }
67
67
 
68
68
  let(:steps) { YAML.safe_load(pipeline)['jobs'].values.flat_map { |job| job['steps'] } }
69
69
 
70
70
  let(:test_steps) do
71
- steps.select { |step| step['run'].to_s.include?('rspec') || step['with'].to_h['script'].to_s.include?('rspec') }
71
+ steps.select { |step| step['run'].to_s.include?(runner) || step['with'].to_h['script'].to_s.include?(runner) }
72
72
  end
73
73
 
74
74
  it 'passes Raider settings to every test step' do
@@ -128,29 +128,19 @@ describe 'CI pipeline content' do
128
128
  end
129
129
  end
130
130
 
131
- shared_examples 'github without raider-comment job' do |project_name, file|
132
- subject(:pipeline) { read_generated(project_name, ".github/workflows/#{file}") }
133
-
134
- it 'has no raider-comment job' do
135
- expect(pipeline).not_to include('raider-comment')
136
- expect(pipeline).not_to include('raider-reporter')
137
- end
138
- end
139
-
140
- shared_examples 'github without raider wiring' do |project_name, file|
141
- subject(:pipeline) { read_generated(project_name, ".github/workflows/#{file}") }
131
+ shared_examples 'github allure steps' do |project_name|
132
+ subject(:pipeline) { read_generated(project_name, '.github/workflows/test_pipeline.yml') }
142
133
 
143
- it 'has no Raider settings or artifact' do
144
- expect(pipeline).not_to include('RAIDER_')
145
- expect(pipeline).not_to include('raider-results')
134
+ it 'keeps the Allure steps' do
135
+ expect(pipeline).to include('simple-elf/allure-report-action', 'peaceiris/actions-gh-pages')
146
136
  end
147
137
  end
148
138
 
149
- shared_examples 'github allure steps' do |project_name|
139
+ shared_examples 'github without allure steps' do |project_name|
150
140
  subject(:pipeline) { read_generated(project_name, '.github/workflows/test_pipeline.yml') }
151
141
 
152
- it 'keeps the Allure steps' do
153
- expect(pipeline).to include('simple-elf/allure-report-action', 'peaceiris/actions-gh-pages')
142
+ it 'has no Allure report or gh-pages steps' do
143
+ expect(pipeline).not_to match(/allure|gh-pages/i)
154
144
  end
155
145
  end
156
146
 
@@ -169,9 +159,9 @@ describe 'CI pipeline content' do
169
159
  include_examples 'valid github pipeline', name
170
160
  include_examples 'github cucumber pipeline', name
171
161
  include_examples 'github triggers', name, 'test_pipeline.yml'
172
- include_examples 'github without raider wiring', name, 'test_pipeline.yml'
173
- include_examples 'github without raider-comment job', name, 'test_pipeline.yml'
174
- include_examples 'github allure steps', name
162
+ include_examples 'github raider wiring', name, 'test_pipeline.yml', 'cucumber'
163
+ include_examples 'github raider-comment job', name, 'test_pipeline.yml', %w[build]
164
+ include_examples 'github without allure steps', name
175
165
  end
176
166
 
177
167
  context 'with rspec and watir' do
@@ -184,6 +174,16 @@ describe 'CI pipeline content' do
184
174
  include_examples 'github allure steps', name
185
175
  end
186
176
 
177
+ context 'with cucumber and watir' do
178
+ name = "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::WATIR}"
179
+ include_examples 'valid github pipeline', name
180
+ include_examples 'github cucumber pipeline', name
181
+ include_examples 'github triggers', name, 'test_pipeline.yml'
182
+ include_examples 'github raider wiring', name, 'test_pipeline.yml', 'cucumber'
183
+ include_examples 'github raider-comment job', name, 'test_pipeline.yml', %w[build]
184
+ include_examples 'github without allure steps', name
185
+ end
186
+
187
187
  appium_jobs = { 'android' => %w[android-tests], 'ios' => %w[ios-tests],
188
188
  'cross_platform' => %w[ios-tests android-tests] }
189
189
 
@@ -198,8 +198,8 @@ describe 'CI pipeline content' do
198
198
  context "with cucumber and #{automation}" do
199
199
  name = "#{FrameworkIndex::CUCUMBER}_#{automation}"
200
200
  include_examples 'github triggers', name, 'appium_pipeline.yml'
201
- include_examples 'github without raider wiring', name, 'appium_pipeline.yml'
202
- include_examples 'github without raider-comment job', name, 'appium_pipeline.yml'
201
+ include_examples 'github raider wiring', name, 'appium_pipeline.yml', 'cucumber'
202
+ include_examples 'github raider-comment job', name, 'appium_pipeline.yml', test_jobs
203
203
  end
204
204
  end
205
205
  end
@@ -122,16 +122,16 @@ describe 'Common file content' do
122
122
  shared_examples 'non-rspec gitignore' do |project_name|
123
123
  subject(:gitignore) { read_generated(project_name, '.gitignore') }
124
124
 
125
- it 'includes allure-results' do
126
- expect(gitignore).to include('allure-results')
125
+ it 'does not include allure-results' do
126
+ expect(gitignore).not_to include('allure-results')
127
127
  end
128
128
 
129
129
  it 'does not include spec/examples.txt' do
130
130
  expect(gitignore).not_to include('spec/examples.txt')
131
131
  end
132
132
 
133
- it 'does not include raider-results' do
134
- expect(gitignore).not_to include('raider-results')
133
+ it 'includes raider-results' do
134
+ expect(gitignore).to include('raider-results')
135
135
  end
136
136
  end
137
137
 
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'content_helper'
4
+
5
+ describe 'Cucumber raider_reporter wiring' do
6
+ screenshot_calls = {
7
+ 'selenium' => 'driver.save_screenshot(screenshot_path)',
8
+ 'watir' => 'browser.screenshot.save(screenshot_path)'
9
+ }
10
+
11
+ AUTOMATION_TYPES.each do |automation|
12
+ context "with cucumber and #{automation}" do
13
+ let(:project_name) { "#{FrameworkIndex::CUCUMBER}_#{automation}" }
14
+ let(:env) { read_generated(project_name, 'features/support/env.rb') }
15
+ let(:screenshot_call) { screenshot_calls.fetch(automation, 'driver.screenshot(screenshot_path)') }
16
+ let(:hook) { env[/^\s*After do.*\z/m] }
17
+ let(:quit_call) { automation == 'watir' ? 'browser.quit' : 'driver.quit' }
18
+
19
+ it 'activates the reporter by requiring raider_reporter/cucumber' do
20
+ expect(env).to include("require 'raider_reporter/cucumber'")
21
+ end
22
+
23
+ it 'does not reference the allure helper' do
24
+ expect(env).not_to match(/AllureHelper|allure_helper/)
25
+ end
26
+
27
+ it 'takes the screenshot only for failed scenarios, inside the guard block' do
28
+ guarded = /if scenario\.failed\?\n\s+Dir\.mktmpdir do \|temp_folder\|\n(?<body>.*?)\n\s+end\n\s+end\nensure/m
29
+ expect(hook.match(guarded)[:body]).to include(screenshot_call)
30
+ end
31
+
32
+ it 'uses a fixed file name in the tmpdir, never the scenario name' do
33
+ expect(env).to include("screenshot_path = File.join(temp_folder, 'screenshot.png')")
34
+ expect(hook).not_to include('scenario.name')
35
+ end
36
+
37
+ it 'quits the driver in an ensure of the After block, without a redundant begin' do
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)
40
+ expect(hook).not_to match(/^\s+begin$/)
41
+ end
42
+
43
+ it 'attaches the screenshot path before quitting the driver' do
44
+ attach = "RaiderReporter.attach('screenshot', screenshot_path, type: :png)"
45
+ expect(env).to include(attach)
46
+ expect(env.index(screenshot_call)).to be < env.index(attach)
47
+ expect(env.index(attach)).to be < env.index(/driver\.quit|browser\.quit/, env.index(attach))
48
+ end
49
+
50
+ it 'does not generate the allure helper' do
51
+ expect(File).not_to exist(File.join(project_name, 'helpers/allure_helper.rb'))
52
+ end
53
+
54
+ it 'does not mention Allure in any generated file' do
55
+ files = Dir.glob("#{project_name}/**/*", File::FNM_DOTMATCH).select { |f| File.file?(f) }
56
+ expect(files.select { |f| File.read(f).match?(/allure/i) }).to be_empty
57
+ end
58
+
59
+ it 'does not create an allure-results folder' do
60
+ expect(File).not_to exist(File.join(project_name, 'allure-results'))
61
+ end
62
+ end
63
+ end
64
+ end
@@ -58,8 +58,8 @@ describe 'Gemfile content' do
58
58
  expect(gemfile).to include("gem 'cucumber'")
59
59
  end
60
60
 
61
- it 'includes allure-cucumber' do
62
- expect(gemfile).to include("gem 'allure-cucumber'")
61
+ it 'does not include allure-cucumber' do
62
+ expect(gemfile).not_to include('allure')
63
63
  end
64
64
 
65
65
  it 'includes rspec as cucumber dependency' do
@@ -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.3'")
130
+ expect(gemfile).to include("gem 'raider_reporter', '~> 0.5'")
131
131
  end
132
132
 
133
133
  it 'keeps allure gems' do
@@ -142,12 +142,12 @@ describe 'Gemfile content' do
142
142
  context "with cucumber and #{automation}" do
143
143
  let(:gemfile) { read_generated("#{FrameworkIndex::CUCUMBER}_#{automation}", 'Gemfile') }
144
144
 
145
- it 'does not include raider_reporter' do
146
- expect(gemfile).not_to include('raider_reporter')
145
+ it 'includes raider_reporter' do
146
+ expect(gemfile).to include("gem 'raider_reporter', '~> 0.5'")
147
147
  end
148
148
 
149
- it 'keeps allure-cucumber' do
150
- expect(gemfile).to include("gem 'allure-cucumber', '2.28.0'")
149
+ it 'drops allure-cucumber' do
150
+ expect(gemfile).not_to include('allure')
151
151
  end
152
152
 
153
153
  it 'pins ruby_raider to the generator major.minor' do
@@ -221,13 +221,11 @@ describe 'Helper file content' do
221
221
  name = "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::SELENIUM}"
222
222
  it_behaves_like 'valid driver helper', name
223
223
  it_behaves_like 'selenium driver helper', name
224
- it_behaves_like 'valid allure helper', name
225
224
  end
226
225
 
227
226
  context 'with cucumber and watir' do
228
227
  name = "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::WATIR}"
229
228
  it_behaves_like 'valid browser helper', name
230
- it_behaves_like 'valid allure helper', name
231
229
  end
232
230
 
233
231
  # --- Mobile contexts (with AppiumRecorder) ---
@@ -255,10 +253,8 @@ describe 'Helper file content' do
255
253
  end
256
254
 
257
255
  context "with cucumber and #{automation}" do
258
- it 'has no raider reporter wiring' do
259
- project = "#{FrameworkIndex::CUCUMBER}_#{automation}"
260
- files = Dir.glob("#{project}/**/*", File::FNM_DOTMATCH).select { |f| File.file?(f) }
261
- expect(files.select { |f| File.read(f).match?(/raider_reporter|RaiderReporter/) }).to be_empty
256
+ it 'does not generate the allure helper' do
257
+ expect(File).not_to exist("#{FrameworkIndex::CUCUMBER}_#{automation}/helpers/allure_helper.rb")
262
258
  end
263
259
  end
264
260
  end
@@ -22,9 +22,19 @@ describe 'Generated Readme content' do
22
22
  end
23
23
 
24
24
  context "with cucumber and #{automation}" do
25
- it 'has no Reporting section' do
26
- readme = read_generated("#{FrameworkIndex::CUCUMBER}_#{automation}", 'Readme.md')
27
- expect(readme).not_to include('RAIDER_TOKEN')
25
+ subject(:readme) { read_generated("#{FrameworkIndex::CUCUMBER}_#{automation}", 'Readme.md') }
26
+
27
+ it 'has a Reporting section' do
28
+ expect(readme).to include('## Reporting')
29
+ end
30
+
31
+ it 'documents local results, upload settings, plugin mode and the raider_reporter README' do
32
+ expect(readme).to include('raider-results/', 'RAIDER_TOKEN', 'RAIDER_ENDPOINT', 'features/support/env.rb',
33
+ 'https://github.com/RaiderHQ/raider_reporter')
34
+ end
35
+
36
+ it 'does not mention Allure' do
37
+ expect(readme).not_to match(/allure/i)
28
38
  end
29
39
  end
30
40
  end
@@ -110,6 +110,10 @@ describe 'Test file content' do
110
110
  it 'has default profile' do
111
111
  expect(config).to include('default:')
112
112
  end
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')
116
+ end
113
117
  end
114
118
 
115
119
  # --- RSpec contexts ---
@@ -84,7 +84,7 @@ describe 'End-to-End Project Generation and Execution' do
84
84
  # Helper to run Cucumber tests
85
85
  def run_cucumber(project_name)
86
86
  puts "\n🥒 Running Cucumber tests in #{project_name}..."
87
- result = run_in_project(project_name, 'bundle exec cucumber features/ --format pretty', timeout: 120)
87
+ result = run_in_project(project_name, 'HEADLESS=true bundle exec cucumber features/ --format pretty', timeout: 120)
88
88
 
89
89
  puts result[:stdout] if result[:stdout].length.positive?
90
90
 
@@ -104,10 +104,10 @@ describe 'End-to-End Project Generation and Execution' do
104
104
  expect(bundle_install(project)).to be true
105
105
  end
106
106
 
107
- it 'resolves raider_reporter 0.3 from RubyGems and ships the raider-reporter executable', :aggregate_failures do
107
+ it 'resolves raider_reporter 0.5 from RubyGems and ships the raider-reporter executable', :aggregate_failures do
108
108
  skip 'Bundle install failed' unless bundle_install(project)
109
109
 
110
- expect(File.read(File.join(project, 'Gemfile.lock'))).to match(/^ raider_reporter \(0\.3\.\d+\)$/)
110
+ expect(File.read(File.join(project, 'Gemfile.lock'))).to match(/^ raider_reporter \(0\.5\.\d+\)$/)
111
111
  result = run_in_project(project, 'bundle exec raider-reporter --help', timeout: 60)
112
112
  expect(result[:exit_code]).to eq(0), "STDOUT: #{result[:stdout]}\nSTDERR: #{result[:stderr]}"
113
113
  expect(result[:stdout] + result[:stderr]).to include('raider-reporter comment')
@@ -162,6 +162,85 @@ describe 'End-to-End Project Generation and Execution' do
162
162
  "STDOUT: #{result[:stdout]}\n" \
163
163
  "STDERR: #{result[:stderr]}"
164
164
  end
165
+
166
+ it 'resolves raider_reporter 0.5 from RubyGems and requires its Cucumber plugin', :aggregate_failures do
167
+ skip 'Bundle install failed' unless bundle_install(project)
168
+
169
+ lockfile = File.read(File.join(project, 'Gemfile.lock'))
170
+ expect(lockfile).to match(/^ raider_reporter \(0\.5\.\d+\)$/)
171
+ expect(File.read(File.join(project, 'features/support/env.rb'))).to include("require 'raider_reporter/cucumber'")
172
+ end
173
+
174
+ it 'prints pretty output and keeps the exit status of a failing Cucumber run', :aggregate_failures, :slow do
175
+ skip 'Bundle install failed' unless bundle_install(project)
176
+
177
+ result = run_cucumber_with_failure(project)
178
+
179
+ expect(result[:stdout]).to include('Feature: Reporter failure')
180
+ expect(result[:stdout]).to include('Scenario: A deliberately failing scenario')
181
+ expect(result[:stdout]).to match(/\d+ scenarios? \(\d+ failed/)
182
+ expect(result[:exit_code]).to eq(1)
183
+ end
184
+
185
+ it 'writes raider-results/run.json listing every scenario with a failing scenario screenshot',
186
+ :aggregate_failures, :slow do
187
+ skip 'Bundle install failed' unless bundle_install(project)
188
+
189
+ result = run_cucumber_with_failure(project)
190
+ summary = result[:stdout].match(/(\d+) scenarios? \((?:(\d+) failed)?/)
191
+ expect(summary).not_to be_nil, "No Cucumber summary line.\nSTDOUT: #{result[:stdout]}\nSTDERR: #{result[:stderr]}"
192
+
193
+ run = JSON.parse(File.read(File.join(project, 'raider-results', 'run.json')))
194
+ expect(run['schema_version']).to eq(1)
195
+ expect(run['results'].size).to eq(summary[1].to_i)
196
+ expect(run['results'].count { |r| r['status'] == 'failed' }).to eq(summary[2].to_i)
197
+
198
+ failing = run['results'].find { |r| r['full_name'] == 'Reporter failure: A deliberately failing scenario' }
199
+ expect(failing).not_to be_nil, "Scenario missing from run.json: #{run['results'].map { |r| r['full_name'] }}"
200
+ expect(failing['status']).to eq('failed')
201
+ expect(failing['file_path']).to eq('features/reporter_failure.feature')
202
+ expect(failing['failure']['message']).to start_with(
203
+ 'Then the reporter records this failure (features/reporter_failure.feature:5)'
204
+ )
205
+ screenshot = failing['attachments'].find { |a| a['name'] == 'screenshot' }
206
+ expect(screenshot).not_to be_nil, "No screenshot attachment: #{failing['attachments']}"
207
+ expect(screenshot['content_type']).to eq('image/png')
208
+ end
209
+ end
210
+
211
+ # Runs the project's features plus a deliberately failing scenario, then removes that scenario again
212
+ def run_cucumber_with_failure(project)
213
+ add_failing_cucumber_scenario(project)
214
+ FileUtils.rm_rf(File.join(project, 'raider-results'))
215
+ run_cucumber(project)
216
+ ensure
217
+ remove_failing_cucumber_scenario(project)
218
+ end
219
+
220
+ # Adds a scenario that always fails at its last step, after the browser has been started
221
+ def add_failing_cucumber_scenario(project)
222
+ File.write(File.join(project, 'features/reporter_failure.feature'), <<~FEATURE)
223
+ Feature: Reporter failure
224
+
225
+ Scenario: A deliberately failing scenario
226
+ Given the reporter has a started scenario
227
+ Then the reporter records this failure
228
+ FEATURE
229
+ File.write(File.join(project, 'features/step_definitions/reporter_failure_steps.rb'), <<~STEPS)
230
+ # frozen_string_literal: true
231
+
232
+ Given('the reporter has a started scenario') {}
233
+
234
+ Then('the reporter records this failure') do
235
+ raise 'deliberate failure for raider_reporter'
236
+ end
237
+ STEPS
238
+ end
239
+
240
+ # Keeps the deliberate failure from leaking into the other examples that share the project
241
+ def remove_failing_cucumber_scenario(project)
242
+ FileUtils.rm_f(File.join(project, 'features/reporter_failure.feature'))
243
+ FileUtils.rm_f(File.join(project, 'features/step_definitions/reporter_failure_steps.rb'))
165
244
  end
166
245
 
167
246
  # Test Web Frameworks (these can run without external services)
@@ -183,6 +262,95 @@ describe 'End-to-End Project Generation and Execution' do
183
262
  end
184
263
  end
185
264
 
265
+ # 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
280
+
281
+ # Generates a ci: azure web project (the shared before(:all) only builds the default github ones)
282
+ def generate_azure_project(framework)
283
+ name = "azure_#{framework}_selenium"
284
+ unless LazyProjectGenerator::GENERATED.include?(name)
285
+ generate_framework(framework:, automation: 'selenium', name:, ci: 'azure')
286
+ LazyProjectGenerator::GENERATED.add(name)
287
+ end
288
+ name
289
+ end
290
+
291
+ def azure_env_prefix
292
+ "GITHUB_ACTIONS= HEADLESS=true #{AZURE_PUSH_ENV.map { |k, v| "#{k}='#{v}'" }.join(' ')}"
293
+ end
294
+
295
+ def expect_azure_ci_block(project)
296
+ run = JSON.parse(File.read(File.join(project, 'raider-results', 'run.json')))
297
+ ci = run.dig('run', 'ci') || run['ci']
298
+ expect(ci).not_to be_nil, "No ci block in run.json: #{run.keys}"
299
+ expect(ci['provider']).to eq('azure_pipelines')
300
+ expect(ci['build_key']).to start_with('azure_pipelines:')
301
+ expect(ci['build_key']).to eq(
302
+ 'azure_pipelines:dev.azure.com/raider-test/6ce954b1-ce1f-45d1-b94d-e6bf2464ba2c/' \
303
+ '1c2bd1a0-3f63-4d93-9c3c-2b4f2b1d8f11:42:1'
304
+ )
305
+ expect(ci['build_url']).to eq('https://dev.azure.com/raider-test/My%20Project/_build/results?buildId=42')
306
+ puts "\nrun.json ci block: #{JSON.pretty_generate(ci)}"
307
+ end
308
+
309
+ context 'Azure Pipelines projects (ci: azure)' do
310
+ describe 'Selenium + RSpec' do
311
+ let(:project) { generate_azure_project('rspec') }
312
+
313
+ it 'ships azure-pipelines.yml and resolves raider_reporter 0.5', :aggregate_failures do
314
+ expect(File).to exist(File.join(project, 'azure-pipelines.yml'))
315
+ expect(File).not_to exist(File.join(project, '.github/workflows'))
316
+ skip 'Bundle install failed' unless bundle_install(project)
317
+
318
+ expect(File.read(File.join(project, 'Gemfile.lock'))).to match(/^ raider_reporter \(0\.5\.\d+\)$/)
319
+ end
320
+
321
+ it 'records provider azure_pipelines and an azure_pipelines: build key in run.json', :aggregate_failures, :slow do
322
+ skip 'Bundle install failed' unless bundle_install(project)
323
+
324
+ FileUtils.rm_rf(File.join(project, 'raider-results'))
325
+ result = run_in_project(project, "#{azure_env_prefix} bundle exec rspec spec/ --format documentation",
326
+ timeout: 120)
327
+ expect(result[:success]).to be(true), "STDOUT: #{result[:stdout]}\nSTDERR: #{result[:stderr]}"
328
+ expect_azure_ci_block(project)
329
+ end
330
+ end
331
+
332
+ describe 'Selenium + Cucumber' do
333
+ let(:project) { generate_azure_project('cucumber') }
334
+
335
+ it 'ships azure-pipelines.yml and resolves raider_reporter 0.5', :aggregate_failures do
336
+ expect(File).to exist(File.join(project, 'azure-pipelines.yml'))
337
+ skip 'Bundle install failed' unless bundle_install(project)
338
+
339
+ expect(File.read(File.join(project, 'Gemfile.lock'))).to match(/^ raider_reporter \(0\.5\.\d+\)$/)
340
+ end
341
+
342
+ it 'records provider azure_pipelines and an azure_pipelines: build key in run.json', :aggregate_failures, :slow do
343
+ skip 'Bundle install failed' unless bundle_install(project)
344
+
345
+ FileUtils.rm_rf(File.join(project, 'raider-results'))
346
+ result = run_in_project(project, "#{azure_env_prefix} bundle exec cucumber features/ --format pretty",
347
+ timeout: 120)
348
+ expect(result[:success]).to be(true), "STDOUT: #{result[:stdout]}\nSTDERR: #{result[:stderr]}"
349
+ expect_azure_ci_block(project)
350
+ end
351
+ end
352
+ end
353
+
186
354
  # Mobile and Visual frameworks require external services, so we only verify structure
187
355
  context 'Mobile Automation Frameworks (structure validation only)' do
188
356
  describe 'iOS + RSpec' do