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.
- checksums.yaml +4 -4
- data/README.md +1 -0
- data/lib/generators/cucumber/templates/feature.tt +5 -0
- data/lib/generators/cucumber/templates/partials/selenium_env.tt +16 -0
- data/lib/generators/cucumber/templates/partials/watir_env.tt +16 -0
- data/lib/generators/cucumber/templates/partials/web_steps.tt +4 -0
- data/lib/generators/helper_generator.rb +7 -0
- data/lib/generators/rspec/templates/spec.tt +12 -0
- data/lib/generators/templates/common/git_ignore.tt +4 -1
- data/lib/generators/templates/common/partials/web_config.tt +4 -1
- data/lib/generators/templates/common/read_me.tt +24 -0
- data/lib/generators/templates/helpers/session_state.tt +206 -0
- data/lib/generators/templates/helpers/spec_helper.tt +20 -0
- data/lib/version +1 -1
- data/ruby_raider.gemspec +1 -0
- data/spec/integration/content/azure_content_spec.rb +4 -0
- data/spec/integration/content/ci_content_spec.rb +11 -0
- data/spec/integration/content/common_content_spec.rb +27 -0
- data/spec/integration/content/config_content_spec.rb +18 -0
- data/spec/integration/content/cucumber_reporter_content_spec.rb +25 -0
- data/spec/integration/content/helper_content_spec.rb +32 -0
- data/spec/integration/content/readme_content_spec.rb +28 -0
- data/spec/integration/content/syntax_validation_spec.rb +15 -0
- data/spec/integration/content/test_content_spec.rb +25 -0
- data/spec/integration/end_to_end_spec.rb +23 -0
- data/spec/integration/generators/helpers_generator_spec.rb +22 -0
- data/spec/integration/helpers/session_state_spec.rb +428 -0
- metadata +18 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e07bc6e38598246845e628bc2f4fde5aff9155fe2a925f7a16465c97b279ba87
|
|
4
|
+
data.tar.gz: 78a6aa99c1790db921530d5f6da67727ac984bd3452fdb46573b8a97d683bbee
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
@@ -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,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'
|
|
@@ -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.
|
|
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
|