ruby_raider 3.10.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 (28) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/lib/generators/cucumber/templates/feature.tt +5 -0
  4. data/lib/generators/cucumber/templates/partials/selenium_env.tt +16 -0
  5. data/lib/generators/cucumber/templates/partials/watir_env.tt +16 -0
  6. data/lib/generators/cucumber/templates/partials/web_steps.tt +4 -0
  7. data/lib/generators/helper_generator.rb +7 -0
  8. data/lib/generators/rspec/templates/spec.tt +12 -0
  9. data/lib/generators/templates/common/git_ignore.tt +4 -1
  10. data/lib/generators/templates/common/partials/web_config.tt +4 -1
  11. data/lib/generators/templates/common/read_me.tt +24 -0
  12. data/lib/generators/templates/helpers/session_state.tt +206 -0
  13. data/lib/generators/templates/helpers/spec_helper.tt +20 -0
  14. data/lib/version +1 -1
  15. data/ruby_raider.gemspec +1 -0
  16. data/spec/integration/content/azure_content_spec.rb +4 -0
  17. data/spec/integration/content/ci_content_spec.rb +11 -0
  18. data/spec/integration/content/common_content_spec.rb +27 -0
  19. data/spec/integration/content/config_content_spec.rb +18 -0
  20. data/spec/integration/content/cucumber_reporter_content_spec.rb +25 -0
  21. data/spec/integration/content/helper_content_spec.rb +32 -0
  22. data/spec/integration/content/readme_content_spec.rb +28 -0
  23. data/spec/integration/content/syntax_validation_spec.rb +15 -0
  24. data/spec/integration/content/test_content_spec.rb +25 -0
  25. data/spec/integration/end_to_end_spec.rb +23 -0
  26. data/spec/integration/generators/helpers_generator_spec.rb +22 -0
  27. data/spec/integration/helpers/session_state_spec.rb +428 -0
  28. metadata +18 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2a901c209fc30681088e9acb4d7c5fb315990db7346128a63533e0e70ef8c58f
4
- data.tar.gz: 1e99b0e973091657bfc10b98d5de1b730d9f7972a2446c56b7a56ddd786f9392
3
+ metadata.gz: e07bc6e38598246845e628bc2f4fde5aff9155fe2a925f7a16465c97b279ba87
4
+ data.tar.gz: 78a6aa99c1790db921530d5f6da67727ac984bd3452fdb46573b8a97d683bbee
5
5
  SHA512:
6
- metadata.gz: 757ad8b61b6c3605d17739690e895198f2ad4c0aadbe4144fc1df77ec743afefd8095472c07e4d2ba4246c515d8038c052d096badc8cfbb04c432218c68ddb45
7
- data.tar.gz: 2e9d2bfb4e714c98052cb6df654be058d37fe40476aa93ce20e9319aeea5bbc24abe4359299b7a9f71f0c204ad19599865f1396794393e634b7200ed29a80bdb
6
+ metadata.gz: e846e3fc291059fd5fe7571011b99e837819dd0adba93bd20016981b14b3854e76f1094c2e93b0a08590c0158ef362c0af4b3fcc49822bc3ce17644a423fe159
7
+ data.tar.gz: 788aed67f16f4ef33ceeb98a8a2bdd6e87a5eef7d5fd3b0a941e036fe76b4ed876ec4ca04d74e5d0ef276f12df251a3076e295d4600e7ee06f0c45ed6c6b804c
data/README.md CHANGED
@@ -61,6 +61,7 @@ Every generated project comes with:
61
61
  - **Page Object Model** — Base page and component classes with example pages
62
62
  - **Test examples** — Working login tests against the [Raider Test Store](https://raider-test-site.onrender.com/)
63
63
  - **Raider reporting** — RSpec and Cucumber projects include [raider_reporter](https://github.com/RaiderHQ/raider_reporter), which writes results to `raider-results/` and uploads runs to your Raider Hideout server when `RAIDER_TOKEN` and `RAIDER_ENDPOINT` are set; generated CI runs the suite through `raider-reporter exec` with `RAIDER_QUARANTINE`, so quarantined tests do not fail the build; an opt-in `raider-gate` job (`RAIDER_GATE=1`) fails CI when the Raider quality gate fails
64
+ - **Login reuse** — generated web projects (Selenium or Watir) log in once and restore the saved session for examples tagged `:logged_in` (RSpec) or `@logged_in` (Cucumber); `RAIDER_LOGIN_STATE=off|refresh` and `login_state: max_age:` control it, and the state stays in git-ignored `.raider/auth/`
64
65
  - **Failure video replay** — Every test is recorded and the video is kept only for failing tests, attached to the report as `video` (WebM from page screenshots for Selenium and Watir, Appium's own MP4 for mobile, which needs `ffmpeg` on the Appium server host for iOS and for Android recordings over 180 s). Turn it off per run with `VIDEO=off`, or per project with `raider u video off`; ffmpeg is downloaded once, SHA-256 checked and cached in `~/.cache/raider`. The RaiderHQ `ffmpeg-7.1` build that the download uses is not published yet, so for now set `RAIDER_FFMPEG_PATH` to an ffmpeg with libvpx to get web videos
65
66
  - **GitHub Actions CI** — Ready-to-use test pipeline that caches ffmpeg for failure videos
66
67
  - **Rake tasks** — Default, smoke, and regression tasks with tag-based filtering
@@ -6,6 +6,11 @@ Scenario: A user can login
6
6
  When I login with my credentials
7
7
  Then I'm logged in
8
8
 
9
+ @logged_in
10
+ Scenario: A logged-in user opens the account page
11
+ Given I'm a registered user
12
+ Then I'm logged in
13
+
9
14
  <%- else -%>
10
15
  Feature: Home Page
11
16
 
@@ -8,6 +8,10 @@ require 'raider_reporter/cucumber'
8
8
  require 'tmpdir'
9
9
  require 'yaml'
10
10
  require_relative '../../helpers/video_helper'
11
+ require_relative '../../helpers/session_state'
12
+ require_relative '../../models/user_factory'
13
+ require_relative '../../page_objects/pages/account'
14
+ require_relative '../../page_objects/pages/login'
11
15
 
12
16
  Before do
13
17
  viewport = YAML.load_file('config/config.yml')['viewport']
@@ -20,6 +24,18 @@ Before do
20
24
  @video_recorder.start
21
25
  end
22
26
 
27
+ Before('@logged_in') do
28
+ user = UserFactory.for('registered user')
29
+ account = Account.new(driver)
30
+ SessionState.new(driver).reuse(
31
+ valid: -> { account.tap(&:visit).header.customer_name == "Welcome back #{user['name']}" }
32
+ ) do
33
+ login = Login.new(driver)
34
+ login.visit
35
+ login.login(user['username'], user['password'])
36
+ end
37
+ end
38
+
23
39
  After do |scenario|
24
40
  video_path = @video_recorder&.stop(keep: scenario.failed?)
25
41
  RaiderReporter.attach('video', video_path, type: :webm) if video_path
@@ -4,6 +4,10 @@ require 'raider_reporter/cucumber'
4
4
  require 'tmpdir'
5
5
  require 'yaml'
6
6
  require_relative '../../helpers/video_helper'
7
+ require_relative '../../helpers/session_state'
8
+ require_relative '../../models/user_factory'
9
+ require_relative '../../page_objects/pages/account'
10
+ require_relative '../../page_objects/pages/login'
7
11
 
8
12
  Before do
9
13
  viewport = YAML.load_file('config/config.yml')['viewport']
@@ -16,6 +20,18 @@ Before do
16
20
  @video_recorder.start
17
21
  end
18
22
 
23
+ Before('@logged_in') do
24
+ user = UserFactory.for('registered user')
25
+ account = Account.new(browser)
26
+ SessionState.new(browser.driver).reuse(
27
+ valid: -> { account.tap(&:visit).header.customer_name == "Welcome back #{user['name']}" }
28
+ ) do
29
+ login = Login.new(browser)
30
+ login.visit
31
+ login.login(user['username'], user['password'])
32
+ end
33
+ end
34
+
19
35
  After do |scenario|
20
36
  video_path = @video_recorder&.stop(keep: scenario.failed?)
21
37
  RaiderReporter.attach('video', video_path, type: :webm) if video_path
@@ -9,6 +9,10 @@ Given("I'm a {user} on the login page") do |user|
9
9
  @login_page.visit
10
10
  end
11
11
 
12
+ Given("I'm a {user}") do |user|
13
+ @user = user
14
+ end
15
+
12
16
  When('I login with my credentials') do
13
17
  @login_page.login(@user['username'], @user['password'])
14
18
  end
@@ -9,6 +9,7 @@ class HelpersGenerator < Generator
9
9
  generate_appium_helper
10
10
  generate_spec_helper
11
11
  generate_video_helper
12
+ generate_session_state
12
13
  end
13
14
 
14
15
  private
@@ -35,6 +36,12 @@ class HelpersGenerator < Generator
35
36
  template('helpers/video_helper.tt', "#{name}/helpers/video_helper.rb")
36
37
  end
37
38
 
39
+ def generate_session_state
40
+ return if mobile?
41
+
42
+ template('helpers/session_state.tt', "#{name}/helpers/session_state.rb")
43
+ end
44
+
38
45
  def generate_appium_helper
39
46
  return unless cross_platform?
40
47
 
@@ -6,6 +6,7 @@ require_relative '../models/model_factory'
6
6
  require_relative '../page_objects/pages/account'
7
7
  require_relative '../page_objects/pages/login'
8
8
 
9
+ # rubocop:disable RSpec/MultipleDescribes
9
10
  describe 'Login' do
10
11
  subject(:header) { account_page.header.customer_name }
11
12
 
@@ -36,6 +37,17 @@ describe 'Login' do
36
37
  end
37
38
  end
38
39
  end
40
+
41
+ describe 'Account', :logged_in do
42
+ let(:user) { ModelFactory.for('users')['registered user'] }
43
+ let(:account_page) { <% if watir? -%>Account.new(browser)<% else -%>Account.new(driver)<% end -%> }
44
+
45
+ it 'opens the account page with the saved login' do
46
+ account_page.visit
47
+ expect(account_page.header.customer_name).to eq "Welcome back #{user['name']}"
48
+ end
49
+ end
50
+ # rubocop:enable RSpec/MultipleDescribes
39
51
  <%- else -%>
40
52
  <% if cross_platform? -%>
41
53
  require_relative '../helpers/appium_helper'
@@ -4,4 +4,7 @@ spec/examples.txt
4
4
  <%- end -%>
5
5
  <%- if cucumber? -%>
6
6
  rerun.txt
7
- <%- end -%>
7
+ <%- end -%>
8
+ <%- if web? -%>
9
+ .raider/auth/
10
+ <%- end -%>
@@ -18,4 +18,7 @@ browser_arguments:
18
18
  video:
19
19
  enabled: true
20
20
  fps: 5
21
- max_width: 1280
21
+ max_width: 1280
22
+
23
+ login_state:
24
+ max_age: 3600 # seconds a saved login is reused before logging in again
@@ -193,6 +193,30 @@ would exit 2 on every run.
193
193
  Parallel test jobs that upload to one build must each set `RAIDER_SHARD_TOTAL` (the number of jobs) and
194
194
  `RAIDER_SHARD_INDEX` (1 to that number), so the gate waits for all of them instead of judging the first one to finish.
195
195
 
196
+ <%- end -%>
197
+ <%- if web? -%>
198
+ ## Reusing the login
199
+
200
+ Tag an example `:logged_in` (RSpec) or a scenario `@logged_in` (Cucumber) and the suite logs in once, saves the
201
+ session, and restores it for the next tagged example or run instead of filling in the login form again. The generated
202
+ account example shows it. If the saved session no longer works, the real login runs again and the state is saved anew.
203
+
204
+ For another role, call it yourself with its own name:
205
+
206
+ ```ruby
207
+ SessionState.new(driver, name: 'admin').reuse(valid: -> { dashboard.tap(&:visit).admin? }) do
208
+ # the real login for the admin
209
+ end
210
+ ```
211
+
212
+ With Watir, pass `browser.driver`, the Selenium driver underneath the browser.
213
+
214
+ - `login_state: max_age:` in `config/config.yml` is how many seconds a saved login stays valid (default 3600).
215
+ - `RAIDER_LOGIN_STATE=off` always does the real login and saves nothing; `RAIDER_LOGIN_STATE=refresh` ignores the saved
216
+ file, logs in and saves again.
217
+ - The state lives in `.raider/auth/` and is git-ignored. It holds live session tokens, so it must not be committed,
218
+ cached or uploaded as a CI artifact. Nothing in it is ever printed.
219
+
196
220
  <%- end -%>
197
221
  <%- if azure? && mobile? -%>
198
222
  ## CI
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'time'
6
+ require 'uri'
7
+ require 'yaml'
8
+
9
+ # Saves the browser login (cookies, localStorage, sessionStorage) to .raider/auth/<name>.json
10
+ # and restores it into a new browser. The file holds live session tokens: it is git-ignored,
11
+ # written 0600, and its values are never printed.
12
+ #
13
+ # SessionState.new(driver, name: 'user').reuse(valid: -> { account.logged_in? }) { log_in }
14
+ #
15
+ # RAIDER_LOGIN_STATE=off always logs in and never saves; =refresh logs in and saves again.
16
+ # Stale age is login_state.max_age (seconds) in config/config.yml.
17
+ class SessionState
18
+ FORMAT = 1
19
+ DEFAULT_MAX_AGE = 3600
20
+ NAME_PATTERN = /\A[a-z0-9_-]+\z/.freeze
21
+ DIR = File.join('.raider', 'auth')
22
+ READ_STORAGE = 'return Object.assign({}, window[arguments[0]]);'
23
+ WRITE_STORAGE = 'var s = window[arguments[0]]; s.clear(); var d = arguments[1]; ' \
24
+ 'Object.keys(d).forEach(function (k) { s.setItem(k, d[k]); });'
25
+ STORAGES = { 'local_storage' => 'localStorage', 'session_storage' => 'sessionStorage' }.freeze
26
+ COOKIE_KEYS = %i[name value domain path secure http_only same_site].freeze
27
+
28
+ attr_reader :name
29
+
30
+ def initialize(driver, name: 'user')
31
+ raise ArgumentError, "name must match #{NAME_PATTERN.source}" unless name.to_s.match?(NAME_PATTERN)
32
+
33
+ @driver = driver
34
+ @name = name.to_s
35
+ end
36
+
37
+ def path
38
+ File.join(DIR, "#{name}.json")
39
+ end
40
+
41
+ def reuse(valid: nil, &login)
42
+ lock = open_lock unless ENV.fetch('RAIDER_LOGIN_STATE', nil) == 'off'
43
+ lock ? reuse_locked(lock, valid, &login) : login.call
44
+ true
45
+ end
46
+
47
+ def save
48
+ ensure_dir
49
+ visit_origin
50
+ state = { 'format' => FORMAT, 'name' => name, 'origin' => origin, 'saved_at' => Time.now.utc.iso8601,
51
+ 'cookies' => @driver.manage.all_cookies.map { |cookie| dump_cookie(cookie) } }
52
+ STORAGES.each { |key, kind| state[key] = @driver.execute_script(READ_STORAGE, kind) || {} }
53
+ write_atomically(JSON.generate(state))
54
+ path
55
+ end
56
+
57
+ def restore
58
+ state = read_state
59
+ usable?(state) && apply(state)
60
+ rescue StandardError => e
61
+ fall_back("could not restore #{path} (#{e.class})")
62
+ end
63
+
64
+ def clear
65
+ FileUtils.rm_f(path)
66
+ end
67
+
68
+ def fresh?
69
+ state = read_state
70
+ usable?(state) && Time.now - Time.iso8601(state['saved_at']) < max_age
71
+ rescue ArgumentError, TypeError
72
+ false
73
+ end
74
+
75
+ private
76
+
77
+ def reuse_locked(lock, valid, &login)
78
+ lock.flock(File::LOCK_EX)
79
+ return if ENV.fetch('RAIDER_LOGIN_STATE', nil) != 'refresh' && fresh? && restore && still_valid?(valid)
80
+
81
+ clear
82
+ reset if @applied
83
+ login.call
84
+ save_after_login
85
+ ensure
86
+ lock.close
87
+ end
88
+
89
+ def open_lock
90
+ ensure_dir
91
+ File.open(File.join(DIR, "#{name}.lock"), File::RDWR | File::CREAT, 0o600)
92
+ rescue SystemCallError => e
93
+ fall_back("cannot write #{DIR} (#{e.class}), so nothing is saved")
94
+ end
95
+
96
+ def save_after_login
97
+ save
98
+ rescue StandardError => e
99
+ warn("[login_state] could not save #{path} (#{e.class}), the next run logs in again")
100
+ end
101
+
102
+ def reset
103
+ visit_origin
104
+ @driver.manage.delete_all_cookies
105
+ STORAGES.each_value { |kind| @driver.execute_script(WRITE_STORAGE, kind, {}) }
106
+ rescue StandardError
107
+ nil # a browser that cannot be reset fails the real login on its own
108
+ end
109
+
110
+ def visit_origin
111
+ @driver.navigate.to("#{origin}/") unless origin_of(URI.parse(@driver.current_url.to_s)) == origin
112
+ end
113
+
114
+ def still_valid?(valid)
115
+ valid.nil? || valid.call || fall_back("login restored from #{path} is no longer valid")
116
+ rescue StandardError => e
117
+ fall_back("validity check failed (#{e.class})")
118
+ end
119
+
120
+ def apply(state)
121
+ @driver.navigate.to("#{origin}/")
122
+ @applied = true
123
+ @driver.manage.delete_all_cookies
124
+ live, skipped = state['cookies'].partition { |cookie| live_for_origin?(cookie) }
125
+ live.each { |cookie| add_cookie(load_cookie(cookie)) }
126
+ warn("[login_state] #{skipped.size} cookies skipped (expired or another domain) from #{path}") if skipped.any?
127
+ STORAGES.each { |key, kind| @driver.execute_script(WRITE_STORAGE, kind, state[key] || {}) }
128
+ @driver.navigate.refresh
129
+ true
130
+ end
131
+
132
+ def add_cookie(attributes)
133
+ @driver.manage.add_cookie(attributes.dup) # Selenium's Manager rewrites the hash it is given
134
+ rescue StandardError
135
+ raise unless attributes.key?(:same_site)
136
+
137
+ @driver.manage.add_cookie(attributes.except(:same_site))
138
+ end
139
+
140
+ def live_for_origin?(cookie)
141
+ (cookie['expires'].nil? || Time.iso8601(cookie['expires']) > Time.now) && domain_matches?(cookie['domain'])
142
+ end
143
+
144
+ def domain_matches?(domain)
145
+ bare = domain.to_s.delete_prefix('.')
146
+ bare.empty? || base_uri.host == bare || base_uri.host.end_with?(".#{bare}")
147
+ end
148
+
149
+ def dump_cookie(cookie)
150
+ COOKIE_KEYS.to_h { |key| [key.to_s, cookie[key]] }.merge('expires' => cookie[:expires]&.to_time&.utc&.iso8601)
151
+ end
152
+
153
+ def load_cookie(cookie)
154
+ COOKIE_KEYS.to_h { |key| [key, cookie[key.to_s]] }
155
+ .merge(expires: cookie['expires'] && Time.iso8601(cookie['expires'])).compact
156
+ end
157
+
158
+ def read_state
159
+ JSON.parse(File.read(path)) if File.exist?(path)
160
+ rescue JSON::ParserError, SystemCallError
161
+ fall_back("#{path} is unreadable or corrupt")
162
+ end
163
+
164
+ def usable?(state)
165
+ return false unless state
166
+ return state['origin'] == origin if state.is_a?(Hash) && state['format'] == FORMAT
167
+
168
+ fall_back("#{path} has an unsupported format")
169
+ end
170
+
171
+ def write_atomically(json)
172
+ temp = "#{path}.#{Process.pid}.#{Thread.current.object_id}.tmp"
173
+ File.write(temp, json, perm: 0o600)
174
+ File.rename(temp, path)
175
+ end
176
+
177
+ def ensure_dir
178
+ FileUtils.chmod(0o700, FileUtils.mkdir_p(DIR))
179
+ end
180
+
181
+ def config
182
+ @config ||= YAML.load_file('config/config.yml') || {}
183
+ end
184
+
185
+ def max_age
186
+ Integer(config.dig('login_state', 'max_age') || DEFAULT_MAX_AGE)
187
+ end
188
+
189
+ def base_uri
190
+ @base_uri ||= URI.parse(config.fetch('url'))
191
+ end
192
+
193
+ def origin
194
+ @origin ||= origin_of(base_uri)
195
+ end
196
+
197
+ def origin_of(uri)
198
+ port = uri.port == uri.default_port ? '' : ":#{uri.port}"
199
+ "#{uri.scheme}://#{uri.host}#{port}"
200
+ end
201
+
202
+ def fall_back(reason)
203
+ warn("[login_state] #{reason}, logging in again")
204
+ false
205
+ end
206
+ end
@@ -13,6 +13,12 @@ require_relative 'browser_helper'
13
13
  require_relative 'driver_helper'
14
14
  <%- end -%>
15
15
  require_relative 'video_helper'
16
+ <%- if web? -%>
17
+ require_relative 'session_state'
18
+ require_relative '../models/model_factory'
19
+ require_relative '../page_objects/pages/login'
20
+ require_relative '../page_objects/pages/account'
21
+ <%- end -%>
16
22
 
17
23
  module SpecHelper
18
24
  RSpec.configure do |config|
@@ -49,6 +55,20 @@ module SpecHelper
49
55
  @video_recorder = VideoHelper.recorder_for(<%= watir? ? 'browser' : 'driver' %>)
50
56
  @video_recorder.start
51
57
  end
58
+ <%- if web? -%>
59
+
60
+ config.before(:each, :logged_in) do
61
+ user = ModelFactory.for('users')['registered user']
62
+ account = Account.new(<%= watir? ? 'browser' : 'driver' %>)
63
+ SessionState.new(<%= watir? ? 'browser.driver' : 'driver' %>).reuse(
64
+ valid: -> { account.tap(&:visit).header.customer_name == "Welcome back #{user['name']}" }
65
+ ) do
66
+ login = Login.new(<%= watir? ? 'browser' : 'driver' %>)
67
+ login.visit
68
+ login.login(user['username'], user['password'])
69
+ end
70
+ end
71
+ <%- end -%>
52
72
 
53
73
  config.after(:each) do |example|
54
74
  example_name = example.description
data/lib/version CHANGED
@@ -1 +1 @@
1
- 3.10.0
1
+ 3.11.0
data/ruby_raider.gemspec CHANGED
@@ -21,6 +21,7 @@ Gem::Specification.new do |s|
21
21
  s.add_development_dependency 'rubocop-performance', '~> 1.15.0'
22
22
  s.add_development_dependency 'rubocop-rspec', '~> 2.9.0'
23
23
  s.add_development_dependency 'ruby_audit', '~> 3.1'
24
+ s.add_development_dependency 'selenium-webdriver', '~> 4.0'
24
25
  s.add_development_dependency 'steep', '~> 1.9'
25
26
 
26
27
  s.add_runtime_dependency 'thor', '~> 1.2.1'
@@ -124,6 +124,10 @@ describe 'Azure Pipelines content' do
124
124
  expect(raw).not_to match(/allure/i)
125
125
  end
126
126
 
127
+ it 'does not mention the saved login state' do
128
+ expect(raw).not_to include('.raider')
129
+ end
130
+
127
131
  it 'documents Azure Pipelines in the README and no GitHub Actions setup' do
128
132
  readme = read_generated(name, 'Readme.md')
129
133
  expect(readme).to include('## Azure Pipelines', 'RAIDER_TOKEN', 'Contribute to pull requests',
@@ -319,4 +319,15 @@ describe 'CI pipeline content' do
319
319
  include_examples 'github raider-gate job', name, 'appium_pipeline.yml', test_jobs
320
320
  end
321
321
  end
322
+
323
+ context 'with the saved login state' do
324
+ workflows = FRAMEWORKS.product(%w[selenium watir]).map { |f, a| ["#{f}_#{a}", 'test_pipeline.yml'] } +
325
+ FRAMEWORKS.product(%w[android ios cross_platform]).map { |f, a| ["#{f}_#{a}", 'appium_pipeline.yml'] }
326
+
327
+ workflows.each do |name, file|
328
+ it "keeps .raider/auth out of #{name}/#{file}" do
329
+ expect(read_generated(name, ".github/workflows/#{file}")).not_to include('.raider')
330
+ end
331
+ end
332
+ end
322
333
  end
@@ -139,6 +139,21 @@ describe 'Common file content' do
139
139
  end
140
140
  end
141
141
 
142
+ shared_examples 'web gitignore for saved logins' do |project_name|
143
+ subject(:lines) { read_generated(project_name, '.gitignore').lines.map(&:strip) }
144
+
145
+ it 'ignores .raider/auth/ but not the whole .raider/' do
146
+ expect(lines).to include('.raider/auth/')
147
+ expect(lines).not_to include('.raider/', '.raider')
148
+ end
149
+ end
150
+
151
+ shared_examples 'mobile gitignore without saved logins' do |project_name|
152
+ it 'has no .raider line' do
153
+ expect(read_generated(project_name, '.gitignore')).not_to include('.raider')
154
+ end
155
+ end
156
+
142
157
  # --- Spec helper retry config (C4) ---
143
158
 
144
159
  shared_examples 'spec helper with retry config' do |project_name|
@@ -207,6 +222,7 @@ describe 'Common file content' do
207
222
  it_behaves_like 'rspec gitignore', name
208
223
  it_behaves_like 'spec helper with retry config', name
209
224
  it_behaves_like 'gemfile with rspec-retry', name
225
+ it_behaves_like 'web gitignore for saved logins', name
210
226
  end
211
227
 
212
228
  context 'with rspec and watir' do
@@ -217,6 +233,7 @@ describe 'Common file content' do
217
233
  it_behaves_like 'rspec gitignore', name
218
234
  it_behaves_like 'spec helper with retry config', name
219
235
  it_behaves_like 'gemfile with rspec-retry', name
236
+ it_behaves_like 'web gitignore for saved logins', name
220
237
  end
221
238
 
222
239
  # --- Cucumber contexts ---
@@ -229,6 +246,7 @@ describe 'Common file content' do
229
246
  it_behaves_like 'non-rspec gitignore', name
230
247
  it_behaves_like 'gemfile without rspec-retry', name
231
248
  it_behaves_like 'cucumber config with retry', name
249
+ it_behaves_like 'web gitignore for saved logins', name
232
250
  end
233
251
 
234
252
  context 'with cucumber and watir' do
@@ -239,5 +257,14 @@ describe 'Common file content' do
239
257
  it_behaves_like 'non-rspec gitignore', name
240
258
  it_behaves_like 'gemfile without rspec-retry', name
241
259
  it_behaves_like 'cucumber config with retry', name
260
+ it_behaves_like 'web gitignore for saved logins', name
261
+ end
262
+
263
+ %w[android ios cross_platform].each do |automation|
264
+ %w[rspec cucumber].each do |framework|
265
+ context "with #{framework} and #{automation}" do
266
+ it_behaves_like 'mobile gitignore without saved logins', "#{framework}_#{automation}"
267
+ end
268
+ end
242
269
  end
243
270
  end
@@ -59,6 +59,13 @@ describe 'Config file content' do
59
59
  end
60
60
  end
61
61
 
62
+ shared_examples 'login state config' do |project_name|
63
+ it 'has a login_state max_age of an hour' do
64
+ config = YAML.safe_load(read_generated(project_name, 'config/config.yml'), permitted_classes: [Symbol])
65
+ expect(config['login_state']).to eq('max_age' => 3600)
66
+ end
67
+ end
68
+
62
69
  # --- Web config per automation ---
63
70
 
64
71
  context 'with rspec and selenium' do
@@ -66,18 +73,21 @@ describe 'Config file content' do
66
73
  include_examples 'valid web config', name
67
74
  include_examples 'selenium config with timeout', name
68
75
  include_examples 'video config', name
76
+ include_examples 'login state config', name
69
77
  end
70
78
 
71
79
  context 'with rspec and watir' do
72
80
  name = "#{FrameworkIndex::RSPEC}_#{AutomationIndex::WATIR}"
73
81
  include_examples 'valid web config', name
74
82
  include_examples 'config without driver_options', name
83
+ include_examples 'login state config', name
75
84
  end
76
85
 
77
86
  context 'with cucumber and selenium' do
78
87
  name = "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::SELENIUM}"
79
88
  include_examples 'valid web config', name
80
89
  include_examples 'selenium config with timeout', name
90
+ include_examples 'login state config', name
81
91
  end
82
92
 
83
93
  # --- Mobile config ---
@@ -100,4 +110,12 @@ describe 'Config file content' do
100
110
  expect(caps).to match(/platformName/i)
101
111
  end
102
112
  end
113
+
114
+ %w[android ios cross_platform].each do |automation|
115
+ it "has no login_state in the #{automation} config" do
116
+ Dir.glob(File.join("#{FrameworkIndex::RSPEC}_#{automation}", 'config', '*.yml')).each do |file|
117
+ expect(File.read(file)).not_to include('login_state')
118
+ end
119
+ end
120
+ end
103
121
  end
@@ -83,3 +83,28 @@ describe 'Cucumber raider_reporter wiring' do
83
83
  end
84
84
  end
85
85
  end
86
+
87
+ describe 'Cucumber login reuse wiring' do
88
+ { 'selenium' => 'driver', 'watir' => 'browser.driver' }.each do |automation, target|
89
+ context "with cucumber and #{automation}" do
90
+ let(:env) { read_generated("#{FrameworkIndex::CUCUMBER}_#{automation}", 'features/support/env.rb') }
91
+
92
+ it 'requires session_state' do
93
+ expect(env).to include("require_relative '../../helpers/session_state'")
94
+ end
95
+
96
+ it "reuses the saved login in Before('@logged_in'), defined after the plain Before" do
97
+ expect(env).to include("Before('@logged_in') do", "SessionState.new(#{target}).reuse(",
98
+ "UserFactory.for('registered user')")
99
+ expect(env.index("Before('@logged_in')")).to be > env.index(/^Before do/)
100
+ end
101
+ end
102
+ end
103
+
104
+ %w[android ios cross_platform].each do |automation|
105
+ it "does not mention session_state with cucumber and #{automation}" do
106
+ env = read_generated("#{FrameworkIndex::CUCUMBER}_#{automation}", 'features/support/env.rb')
107
+ expect(env).not_to include('session_state')
108
+ end
109
+ end
110
+ end
@@ -365,4 +365,36 @@ describe 'Helper file content' do
365
365
  end
366
366
  end
367
367
  end
368
+
369
+ # --- Opt-in login reuse ---
370
+
371
+ { 'selenium' => 'driver', 'watir' => 'browser.driver' }.each do |automation, target|
372
+ context "with rspec and #{automation} login reuse" do
373
+ subject(:helper) { read_generated("#{FrameworkIndex::RSPEC}_#{automation}", 'helpers/spec_helper.rb') }
374
+
375
+ it 'requires session_state and opts in through the :logged_in tag' do
376
+ expect(helper).to include("require_relative 'session_state'", 'config.before(:each, :logged_in)')
377
+ end
378
+
379
+ it 'reuses the saved login for the driver, falling back to the real login' do
380
+ expect(helper).to include("SessionState.new(#{target}).reuse(")
381
+ expect(helper).to include("user = ModelFactory.for('users')['registered user']", ".login(user['username'], user['password'])")
382
+ expect(helper).to include("header.customer_name == \"Welcome back \#{user['name']}\"")
383
+ end
384
+
385
+ it 'requires the models and pages the login needs' do
386
+ expect(helper).to include("require_relative '../models/model_factory'",
387
+ "require_relative '../page_objects/pages/login'",
388
+ "require_relative '../page_objects/pages/account'")
389
+ end
390
+ end
391
+ end
392
+
393
+ %w[android ios cross_platform].each do |automation|
394
+ context "with rspec and #{automation} without login reuse" do
395
+ it 'does not mention session_state' do
396
+ expect(read_generated("#{FrameworkIndex::RSPEC}_#{automation}", 'helpers/spec_helper.rb')).not_to include('session_state')
397
+ end
398
+ end
399
+ end
368
400
  end