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.
- checksums.yaml +4 -4
- data/.github/workflows/release.yml +18 -29
- data/.github/workflows/security.yml +68 -0
- data/.rubocop.yml +28 -0
- data/README.md +23 -4
- data/docs/superpowers/plans/2026-10-25-failure-video-replay.md +400 -0
- data/docs/superpowers/plans/2026-10-26-parity-cleanup.md +233 -0
- data/docs/superpowers/plans/2026-10-28-failure-card.md +295 -0
- data/docs/superpowers/specs/2026-10-25-failure-video-replay-design.md +214 -0
- data/docs/superpowers/specs/2026-10-28-failure-card-design.md +210 -0
- data/lib/commands/scaffolding_commands.rb +3 -3
- data/lib/commands/utility_commands.rb +36 -6
- data/lib/generators/cucumber/templates/cucumber.tt +1 -1
- data/lib/generators/cucumber/templates/feature.tt +5 -0
- data/lib/generators/cucumber/templates/partials/appium_env.tt +6 -0
- data/lib/generators/cucumber/templates/partials/selenium_env.tt +22 -0
- data/lib/generators/cucumber/templates/partials/watir_env.tt +22 -0
- data/lib/generators/cucumber/templates/partials/web_steps.tt +4 -0
- data/lib/generators/helper_generator.rb +12 -1
- data/lib/generators/infrastructure/azure_generator.rb +1 -0
- data/lib/generators/infrastructure/github_generator.rb +1 -0
- data/lib/generators/infrastructure/templates/azure_pipelines.tt +12 -1
- data/lib/generators/infrastructure/templates/github.tt +12 -0
- data/lib/generators/infrastructure/templates/github_appium.tt +23 -0
- data/lib/generators/invoke_generators.rb +20 -2
- data/lib/generators/rspec/rspec_generator.rb +0 -1
- data/lib/generators/rspec/templates/spec.tt +12 -0
- data/lib/generators/templates/common/gemfile.tt +1 -1
- data/lib/generators/templates/common/git_ignore.tt +7 -1
- data/lib/generators/templates/common/partials/mobile_config.tt +5 -0
- data/lib/generators/templates/common/partials/web_config.tt +9 -1
- data/lib/generators/templates/common/read_me.tt +84 -2
- data/lib/generators/templates/helpers/browser_helper.tt +5 -1
- data/lib/generators/templates/helpers/driver_helper.tt +1 -0
- data/lib/generators/templates/helpers/partials/selenium_driver.tt +4 -1
- data/lib/generators/templates/helpers/session_state.tt +206 -0
- data/lib/generators/templates/helpers/spec_helper.tt +28 -0
- data/lib/generators/templates/helpers/video_helper.tt +338 -171
- data/lib/ruby_raider/ffmpeg.rb +280 -0
- data/lib/ruby_raider.rb +2 -3
- data/lib/scaffolding/project_detector.rb +1 -3
- data/lib/scaffolding/scaffolding.rb +0 -1
- data/lib/utilities/utilities.rb +15 -2
- data/lib/version +1 -1
- data/ruby_raider.gemspec +4 -1
- data/sig/commands/utility_commands.rbs +4 -1
- data/sig/generators/invoke_generators.rbs +2 -3
- data/sig/ruby_raider/ffmpeg.rbs +23 -0
- data/sig/utilities/utilities.rbs +1 -0
- data/spec/commands/raider_commands_spec.rb +56 -1
- data/spec/generators/fixtures/video_helper_stubs/selenium-webdriver.rb +28 -0
- data/spec/generators/video_helper_spec.rb +608 -0
- data/spec/integration/commands/browser_update_after_creation_spec.rb +7 -6
- data/spec/integration/commands/utility_commands_spec.rb +2 -2
- data/spec/integration/content/azure_content_spec.rb +33 -0
- data/spec/integration/content/ci_content_spec.rb +54 -0
- data/spec/integration/content/common_content_spec.rb +35 -0
- data/spec/integration/content/config_content_spec.rb +32 -0
- data/spec/integration/content/cucumber_reporter_content_spec.rb +47 -1
- data/spec/integration/content/gemfile_content_spec.rb +2 -3
- data/spec/integration/content/helper_content_spec.rb +178 -0
- data/spec/integration/content/page_content_spec.rb +0 -1
- data/spec/integration/content/readme_content_spec.rb +79 -0
- data/spec/integration/content/rspec_reporter_content_spec.rb +2 -2
- data/spec/integration/content/syntax_validation_spec.rb +15 -0
- data/spec/integration/content/test_content_spec.rb +27 -3
- data/spec/integration/end_to_end_features_spec.rb +0 -2
- data/spec/integration/end_to_end_spec.rb +275 -26
- data/spec/integration/generators/azure_generator_spec.rb +7 -6
- data/spec/integration/generators/common_generator_spec.rb +1 -1
- data/spec/integration/generators/helpers_generator_spec.rb +22 -0
- data/spec/integration/generators/invoke_generators_spec.rb +52 -0
- data/spec/integration/helpers/session_state_spec.rb +428 -0
- data/spec/integration/scaffolding_e2e_spec.rb +13 -24
- data/spec/scaffolding/scaffold_project_detector_spec.rb +0 -1
- data/spec/scaffolding/scaffolding_features_spec.rb +0 -1
- data/spec/system/selenium_spec.rb +0 -1
- data/spec/system/watir_spec.rb +0 -1
- data/spec/utilities/ffmpeg_spec.rb +424 -0
- data/spec/utilities/utilities_spec.rb +19 -2
- data/spec/utilities/video_command_spec.rb +120 -0
- metadata +65 -11
- data/lib/commands/adopt_commands.rb +0 -42
- data/lib/commands/loaded_commands.rb +0 -9
- data/lib/commands/plugin_commands.rb +0 -32
- data/sig/commands/adopt_commands.rbs +0 -8
- data/sig/commands/loaded_commands.rbs +0 -5
- data/sig/commands/plugin_commands.rbs +0 -9
|
@@ -8,6 +8,8 @@ class HelpersGenerator < Generator
|
|
|
8
8
|
generate_driver_helper
|
|
9
9
|
generate_appium_helper
|
|
10
10
|
generate_spec_helper
|
|
11
|
+
generate_video_helper
|
|
12
|
+
generate_session_state
|
|
11
13
|
end
|
|
12
14
|
|
|
13
15
|
private
|
|
@@ -30,10 +32,19 @@ class HelpersGenerator < Generator
|
|
|
30
32
|
template('helpers/driver_helper.tt', "#{name}/helpers/driver_helper.rb")
|
|
31
33
|
end
|
|
32
34
|
|
|
35
|
+
def generate_video_helper
|
|
36
|
+
template('helpers/video_helper.tt', "#{name}/helpers/video_helper.rb")
|
|
37
|
+
end
|
|
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
|
+
|
|
33
45
|
def generate_appium_helper
|
|
34
46
|
return unless cross_platform?
|
|
35
47
|
|
|
36
48
|
template('helpers/appium_helper.tt', "#{name}/helpers/appium_helper.rb")
|
|
37
49
|
end
|
|
38
|
-
|
|
39
50
|
end
|
|
@@ -12,7 +12,9 @@ pool:
|
|
|
12
12
|
|
|
13
13
|
variables:
|
|
14
14
|
HEADLESS: 'true'
|
|
15
|
-
#
|
|
15
|
+
# Where ffmpeg for failure videos is cached between runs.
|
|
16
|
+
RAIDER_CACHE_DIR: $(Pipeline.Workspace)/.cache/raider
|
|
17
|
+
# Add RAIDER_TOKEN (secret) and RAIDER_ENDPOINT (your Raider Hideout URL) as pipeline variables.
|
|
16
18
|
|
|
17
19
|
jobs:
|
|
18
20
|
- job: tests
|
|
@@ -21,6 +23,15 @@ jobs:
|
|
|
21
23
|
inputs: { versionSpec: '3.4' }
|
|
22
24
|
- script: bundle install
|
|
23
25
|
displayName: Install gems
|
|
26
|
+
- task: Cache@2
|
|
27
|
+
displayName: Cache ffmpeg for failure videos
|
|
28
|
+
inputs:
|
|
29
|
+
key: 'raider-ffmpeg-<%= RubyRaider::Ffmpeg::MANIFEST[:version] %> | "$(Agent.OS)" | Gemfile.lock'
|
|
30
|
+
path: $(RAIDER_CACHE_DIR)
|
|
31
|
+
# Video is optional: a failed download is visible here but never fails the build.
|
|
32
|
+
- script: bundle exec raider u video install
|
|
33
|
+
displayName: Install ffmpeg for failure videos
|
|
34
|
+
continueOnError: true
|
|
24
35
|
- script: <%- if cucumber? -%>bundle exec raider-reporter exec -- bundle exec cucumber features/ --format pretty<%- else -%>bundle exec raider-reporter exec -- bundle exec rspec spec/ --format documentation<%- end %>
|
|
25
36
|
displayName: Run tests
|
|
26
37
|
env:
|
|
@@ -33,8 +33,20 @@ jobs:
|
|
|
33
33
|
with:
|
|
34
34
|
chrome-version: stable
|
|
35
35
|
|
|
36
|
+
- name: Cache ffmpeg for failure videos
|
|
37
|
+
uses: actions/cache@v4
|
|
38
|
+
with:
|
|
39
|
+
path: ~/.cache/raider
|
|
40
|
+
key: raider-ffmpeg-<%= RubyRaider::Ffmpeg::MANIFEST[:version] %>-${{ runner.os }}-${{ hashFiles('**/Gemfile.lock') }}
|
|
41
|
+
|
|
42
|
+
- name: Install ffmpeg for failure videos
|
|
43
|
+
run: bundle exec raider u video install
|
|
44
|
+
# Video is optional: a failed download is visible here but never fails the build.
|
|
45
|
+
continue-on-error: true
|
|
46
|
+
|
|
36
47
|
- name: Run tests
|
|
37
48
|
run: <%- if framework == 'cucumber' -%>bundle exec raider-reporter exec -- bundle exec cucumber features --format pretty<%- else -%>bundle exec raider-reporter exec -- bundle exec rspec spec --format documentation<%- end %>
|
|
49
|
+
# RAIDER_ENDPOINT: your Raider Hideout URL (repository variable); RAIDER_TOKEN: a project token (secret).
|
|
38
50
|
env:
|
|
39
51
|
HEADLESS: true
|
|
40
52
|
RAIDER_TOKEN: ${{ secrets.RAIDER_TOKEN }}
|
|
@@ -44,12 +44,24 @@ jobs:
|
|
|
44
44
|
appium &
|
|
45
45
|
sleep 5
|
|
46
46
|
|
|
47
|
+
- name: Cache ffmpeg for failure videos
|
|
48
|
+
uses: actions/cache@v4
|
|
49
|
+
with:
|
|
50
|
+
path: ~/.cache/raider
|
|
51
|
+
key: raider-ffmpeg-<%= RubyRaider::Ffmpeg::MANIFEST[:version] %>-${{ runner.os }}-${{ hashFiles('**/Gemfile.lock') }}
|
|
52
|
+
|
|
53
|
+
- name: Install ffmpeg for failure videos
|
|
54
|
+
run: bundle exec raider u video install
|
|
55
|
+
# Video is optional: a failed download is visible here but never fails the build.
|
|
56
|
+
continue-on-error: true
|
|
57
|
+
|
|
47
58
|
- name: Run tests
|
|
48
59
|
<%- if cucumber? -%>
|
|
49
60
|
run: bundle exec raider-reporter exec -- bundle exec cucumber features/ --format pretty
|
|
50
61
|
<%- else -%>
|
|
51
62
|
run: bundle exec raider-reporter exec -- bundle exec rspec spec/ --format documentation
|
|
52
63
|
<%- end -%>
|
|
64
|
+
# RAIDER_ENDPOINT: your Raider Hideout URL (repository variable); RAIDER_TOKEN: a project token (secret).
|
|
53
65
|
env:
|
|
54
66
|
APP_PATH: ${{ github.workspace }}/apps/RaiderTestApp.app
|
|
55
67
|
RAIDER_TOKEN: ${{ secrets.RAIDER_TOKEN }}
|
|
@@ -101,6 +113,17 @@ jobs:
|
|
|
101
113
|
appium &
|
|
102
114
|
sleep 5
|
|
103
115
|
|
|
116
|
+
- name: Cache ffmpeg for failure videos
|
|
117
|
+
uses: actions/cache@v4
|
|
118
|
+
with:
|
|
119
|
+
path: ~/.cache/raider
|
|
120
|
+
key: raider-ffmpeg-<%= RubyRaider::Ffmpeg::MANIFEST[:version] %>-${{ runner.os }}-${{ hashFiles('**/Gemfile.lock') }}
|
|
121
|
+
|
|
122
|
+
- name: Install ffmpeg for failure videos
|
|
123
|
+
run: bundle exec raider u video install
|
|
124
|
+
# Video is optional: a failed download is visible here but never fails the build.
|
|
125
|
+
continue-on-error: true
|
|
126
|
+
|
|
104
127
|
- name: Run Android Emulator and Tests
|
|
105
128
|
uses: reactivecircus/android-emulator-runner@v2
|
|
106
129
|
with:
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require 'thor'
|
|
1
2
|
require_relative 'infrastructure/github_generator'
|
|
2
3
|
require_relative 'infrastructure/azure_generator'
|
|
3
4
|
require_relative 'automation/automation_generator'
|
|
@@ -7,11 +8,15 @@ require_relative 'helper_generator'
|
|
|
7
8
|
require_relative 'rspec/rspec_generator'
|
|
8
9
|
|
|
9
10
|
module InvokeGenerators
|
|
10
|
-
|
|
11
|
+
FRAMEWORKS = %w[rspec cucumber].freeze
|
|
12
|
+
AUTOMATIONS = %w[selenium watir ios android cross_platform].freeze
|
|
11
13
|
|
|
12
14
|
module_function
|
|
13
15
|
|
|
14
16
|
def generate_framework(structure = {})
|
|
17
|
+
structure = normalise_options(structure)
|
|
18
|
+
validate_option(:framework, structure[:framework], FRAMEWORKS)
|
|
19
|
+
validate_option(:automation, structure[:automation], AUTOMATIONS)
|
|
15
20
|
validate_ci(structure[:ci])
|
|
16
21
|
generators = %w[Automation Common Helpers]
|
|
17
22
|
framework = structure[:framework]
|
|
@@ -30,10 +35,23 @@ module InvokeGenerators
|
|
|
30
35
|
print_mobile_azure_notice(structure)
|
|
31
36
|
end
|
|
32
37
|
|
|
38
|
+
def normalise_options(structure)
|
|
39
|
+
structure.merge(
|
|
40
|
+
framework: structure[:framework].to_s.downcase,
|
|
41
|
+
automation: structure[:automation].to_s.downcase
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_option(key, value, allowed)
|
|
46
|
+
return if allowed.include?(value.to_s)
|
|
47
|
+
|
|
48
|
+
raise Thor::Error, "Invalid #{key} #{value.to_s.inspect}. Allowed values: #{allowed.join(', ')}"
|
|
49
|
+
end
|
|
50
|
+
|
|
33
51
|
def validate_ci(provider)
|
|
34
52
|
return if [nil, '', 'github', 'azure'].include?(provider&.to_s)
|
|
35
53
|
|
|
36
|
-
raise
|
|
54
|
+
raise Thor::Error, 'ci must be github or azure'
|
|
37
55
|
end
|
|
38
56
|
|
|
39
57
|
def print_mobile_azure_notice(structure)
|
|
@@ -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'
|
|
@@ -13,4 +13,12 @@ browser_arguments:
|
|
|
13
13
|
- search-engine-choice-country
|
|
14
14
|
firefox:
|
|
15
15
|
- acceptInsecureCerts
|
|
16
|
-
- no-sandbox
|
|
16
|
+
- no-sandbox
|
|
17
|
+
|
|
18
|
+
video:
|
|
19
|
+
enabled: true
|
|
20
|
+
fps: 5
|
|
21
|
+
max_width: 1280
|
|
22
|
+
|
|
23
|
+
login_state:
|
|
24
|
+
max_age: 3600 # seconds a saved login is reused before logging in again
|
|
@@ -93,11 +93,13 @@ Every run writes machine-readable results to `raider-results/` (including `run.j
|
|
|
93
93
|
`features/support/env.rb` requires `raider_reporter/cucumber`, so no extra Cucumber formatter flag is needed.
|
|
94
94
|
<%- end -%>
|
|
95
95
|
|
|
96
|
-
To upload runs to Raider, set these environment variables before running the suite
|
|
96
|
+
To upload runs to Raider, set these environment variables before running the suite.
|
|
97
|
+
`RAIDER_TOKEN` and `RAIDER_ENDPOINT` come from your Raider Hideout server (its setup page, or `bin/rails hideout:setup` /
|
|
98
|
+
`hideout:token`):
|
|
97
99
|
|
|
98
100
|
```bash
|
|
99
101
|
export RAIDER_TOKEN=your-token
|
|
100
|
-
export RAIDER_ENDPOINT=https://
|
|
102
|
+
export RAIDER_ENDPOINT=https://hideout.example.com
|
|
101
103
|
```
|
|
102
104
|
|
|
103
105
|
<%- if rspec? -%>
|
|
@@ -108,6 +110,62 @@ Without `RAIDER_TOKEN`, nothing is uploaded and the suite output is unchanged. F
|
|
|
108
110
|
report and to Raider. See the [raider_reporter README](https://github.com/RaiderHQ/raider_reporter) for details.
|
|
109
111
|
<%- end -%>
|
|
110
112
|
|
|
113
|
+
### Video replay
|
|
114
|
+
|
|
115
|
+
Every test is recorded, and the video is kept only for failing tests<%= rspec? ? ' (on the last retry attempt)' : '' %>. It is attached
|
|
116
|
+
to the report as `video` and saved under `raider-results/attachments/`, next to the failure screenshot.
|
|
117
|
+
<%- if mobile? -%>
|
|
118
|
+
Appium records the device screen and the video is attached as `video.mp4`.
|
|
119
|
+
<%- else -%>
|
|
120
|
+
The video is a `video.webm` built from page screenshots with ffmpeg.
|
|
121
|
+
<%- end -%>
|
|
122
|
+
|
|
123
|
+
<%- if single_platform? -%>
|
|
124
|
+
- Turn video off for one run with `VIDEO=off bundle exec <%= rspec? ? 'rspec' : 'cucumber' %>`, or for the project with
|
|
125
|
+
`raider u video off`, which creates `config/config.yml` with a `video:` section (`raider u video on` turns it back
|
|
126
|
+
on).
|
|
127
|
+
<%- else -%>
|
|
128
|
+
- Video is on by default: `video:` in `config/config.yml` (`enabled: true`, `fps: 5`, `max_width: 1280`).
|
|
129
|
+
- Turn it off for one run with `VIDEO=off bundle exec <%= rspec? ? 'rspec' : 'cucumber' %>`, or for the project with
|
|
130
|
+
`raider u video off` (`raider u video on` turns it back on).
|
|
131
|
+
<%- end -%>
|
|
132
|
+
<%- unless mobile? -%>
|
|
133
|
+
- ffmpeg is downloaded on first use, checked against a pinned SHA-256 and cached in `~/.cache/raider`
|
|
134
|
+
(`%LOCALAPPDATA%\raider` on Windows). `raider u video install` downloads it ahead of time (CI does this before the
|
|
135
|
+
tests and caches the folder), and `raider u video status` prints the binary in use.
|
|
136
|
+
- `RAIDER_FFMPEG_PATH` uses your own ffmpeg (it needs libvpx) instead of the download, and `RAIDER_CACHE_DIR` moves
|
|
137
|
+
the cache.
|
|
138
|
+
<%- if azure? -%>
|
|
139
|
+
- `azure-pipelines.yml` sets `RAIDER_CACHE_DIR` to `$(Pipeline.Workspace)/.cache/raider`, the folder its `Cache@2`
|
|
140
|
+
step keeps between runs.
|
|
141
|
+
<%- end -%>
|
|
142
|
+
- The RaiderHQ `ffmpeg-7.1` release that the download comes from is not published yet. Until it is, set
|
|
143
|
+
`RAIDER_FFMPEG_PATH` to get videos; without it the suite runs as usual and prints one `[video]` line.
|
|
144
|
+
- If ffmpeg is missing or recording fails, the suite still runs and its exit code is unchanged; you get one `[video]`
|
|
145
|
+
line on stderr and no video.
|
|
146
|
+
- Known limits: a frame is taken after each page load, click and text change, plus one at the end of a failing test,
|
|
147
|
+
so changes the page makes on its own between actions show up in the next frame. There is one recorded driver per
|
|
148
|
+
process.
|
|
149
|
+
<%- else -%>
|
|
150
|
+
- Appium's screen recording needs `ffmpeg` on the Appium server host's `PATH` (this machine only when Appium runs
|
|
151
|
+
here): always for iOS (XCUITest), and for Android when a recording is longer than 180 s. Without it, recording
|
|
152
|
+
fails with one `[video]` line and the tests run as usual.
|
|
153
|
+
<%- if cross_platform? -%>
|
|
154
|
+
- Each test records for at most 1800 s on iOS and 180 s on Android, so Android needs no ffmpeg on the Appium host.
|
|
155
|
+
<%- elsif ios? -%>
|
|
156
|
+
- Each test records for at most 1800 s.
|
|
157
|
+
<%- else -%>
|
|
158
|
+
- Each test records for at most 180 s, so the Appium host needs no ffmpeg; longer tests keep only their first 180 s.
|
|
159
|
+
<%- end -%>
|
|
160
|
+
Set `time_limit` (seconds) under `video:` in `config/config.yml`<%= single_platform? ? ' (create it if it is missing)' : '' %> to change it; on Android, more
|
|
161
|
+
than 180 s needs ffmpeg on the Appium host.
|
|
162
|
+
- `raider u video install` and `raider u video status` manage the ffmpeg that web projects use, cached in
|
|
163
|
+
`~/.cache/raider` (override with `RAIDER_FFMPEG_PATH` or `RAIDER_CACHE_DIR`); they do not install ffmpeg on the
|
|
164
|
+
Appium host. The RaiderHQ `ffmpeg-7.1` release it comes from is not published yet.
|
|
165
|
+
- If recording fails, the suite still runs and its exit code is unchanged; you get one `[video]` line on stderr and
|
|
166
|
+
no video.
|
|
167
|
+
<%- end -%>
|
|
168
|
+
|
|
111
169
|
<%- unless azure? && mobile? -%>
|
|
112
170
|
### Quarantine
|
|
113
171
|
|
|
@@ -135,6 +193,30 @@ would exit 2 on every run.
|
|
|
135
193
|
Parallel test jobs that upload to one build must each set `RAIDER_SHARD_TOTAL` (the number of jobs) and
|
|
136
194
|
`RAIDER_SHARD_INDEX` (1 to that number), so the gate waits for all of them instead of judging the first one to finish.
|
|
137
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
|
+
|
|
138
220
|
<%- end -%>
|
|
139
221
|
<%- if azure? && mobile? -%>
|
|
140
222
|
## CI
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'active_support/core_ext/string/inflections'
|
|
4
4
|
require 'yaml'
|
|
5
5
|
require 'watir'
|
|
6
|
+
require_relative 'video_helper'
|
|
6
7
|
|
|
7
8
|
module BrowserHelper
|
|
8
9
|
|
|
@@ -23,6 +24,9 @@ module BrowserHelper
|
|
|
23
24
|
args += ['--headless'] if (ENV['HEADLESS'] || config['headless']) && !args.include?('--headless')
|
|
24
25
|
capitalized = browser_name == 'ie' ? browser_name.upcase : browser_name.capitalize
|
|
25
26
|
browser_options = "Selenium::WebDriver::#{capitalized}::Options".constantize.new(args:)
|
|
26
|
-
|
|
27
|
+
arguments = { options: browser_options }
|
|
28
|
+
listener = VideoHelper.listener
|
|
29
|
+
arguments[:listener] = listener if listener
|
|
30
|
+
Watir::Browser.new(browser_name.to_sym, **arguments)
|
|
27
31
|
end
|
|
28
32
|
end
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
def create_driver(*opts)
|
|
2
2
|
@config = YAML.load_file('config/config.yml')
|
|
3
3
|
browser = @config['browser']
|
|
4
|
-
|
|
4
|
+
arguments = { options: create_webdriver_options(*opts) }
|
|
5
|
+
listener = VideoHelper.listener
|
|
6
|
+
arguments[:listener] = listener if listener
|
|
7
|
+
driver = Selenium::WebDriver.for(browser.to_sym, **arguments)
|
|
5
8
|
driver.manage.timeouts.implicit_wait = @config.fetch('timeout', 10)
|
|
6
9
|
driver
|
|
7
10
|
end
|
|
@@ -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
|
|
@@ -12,6 +12,13 @@ require_relative 'browser_helper'
|
|
|
12
12
|
<%- else -%>
|
|
13
13
|
require_relative 'driver_helper'
|
|
14
14
|
<%- end -%>
|
|
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 -%>
|
|
15
22
|
|
|
16
23
|
module SpecHelper
|
|
17
24
|
RSpec.configure do |config|
|
|
@@ -45,10 +52,28 @@ module SpecHelper
|
|
|
45
52
|
browser.window.maximize
|
|
46
53
|
end
|
|
47
54
|
<%- end -%>
|
|
55
|
+
@video_recorder = VideoHelper.recorder_for(<%= watir? ? 'browser' : 'driver' %>)
|
|
56
|
+
@video_recorder.start
|
|
48
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 -%>
|
|
49
72
|
|
|
50
73
|
config.after(:each) do |example|
|
|
51
74
|
example_name = example.description
|
|
75
|
+
keep_video = example.exception ? VideoHelper.final_attempt?(example) : false
|
|
76
|
+
video_path = @video_recorder&.stop(keep: keep_video)
|
|
52
77
|
if example.exception
|
|
53
78
|
@_exception = example.exception
|
|
54
79
|
Dir.mktmpdir do |temp_folder|
|
|
@@ -57,6 +82,9 @@ module SpecHelper
|
|
|
57
82
|
RaiderReporter.attach('screenshot', screenshot_path, type: :png)
|
|
58
83
|
end
|
|
59
84
|
end
|
|
85
|
+
RaiderReporter.attach('video', video_path, type: <%= mobile? ? ':mp4' : ':webm' %>) if video_path
|
|
86
|
+
ensure
|
|
87
|
+
@video_recorder&.cleanup
|
|
60
88
|
<%= partial('quit_driver', strip: true) %>
|
|
61
89
|
end
|
|
62
90
|
end
|