ruby_raider 3.9.0 → 3.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/release.yml +18 -29
  3. data/.github/workflows/security.yml +68 -0
  4. data/.rubocop.yml +28 -0
  5. data/README.md +23 -4
  6. data/docs/superpowers/plans/2026-10-25-failure-video-replay.md +400 -0
  7. data/docs/superpowers/plans/2026-10-26-parity-cleanup.md +233 -0
  8. data/docs/superpowers/plans/2026-10-28-failure-card.md +295 -0
  9. data/docs/superpowers/specs/2026-10-25-failure-video-replay-design.md +214 -0
  10. data/docs/superpowers/specs/2026-10-28-failure-card-design.md +210 -0
  11. data/lib/commands/scaffolding_commands.rb +3 -3
  12. data/lib/commands/utility_commands.rb +36 -6
  13. data/lib/generators/cucumber/templates/cucumber.tt +1 -1
  14. data/lib/generators/cucumber/templates/feature.tt +5 -0
  15. data/lib/generators/cucumber/templates/partials/appium_env.tt +6 -0
  16. data/lib/generators/cucumber/templates/partials/selenium_env.tt +22 -0
  17. data/lib/generators/cucumber/templates/partials/watir_env.tt +22 -0
  18. data/lib/generators/cucumber/templates/partials/web_steps.tt +4 -0
  19. data/lib/generators/helper_generator.rb +12 -1
  20. data/lib/generators/infrastructure/azure_generator.rb +1 -0
  21. data/lib/generators/infrastructure/github_generator.rb +1 -0
  22. data/lib/generators/infrastructure/templates/azure_pipelines.tt +12 -1
  23. data/lib/generators/infrastructure/templates/github.tt +12 -0
  24. data/lib/generators/infrastructure/templates/github_appium.tt +23 -0
  25. data/lib/generators/invoke_generators.rb +20 -2
  26. data/lib/generators/rspec/rspec_generator.rb +0 -1
  27. data/lib/generators/rspec/templates/spec.tt +12 -0
  28. data/lib/generators/templates/common/gemfile.tt +1 -1
  29. data/lib/generators/templates/common/git_ignore.tt +7 -1
  30. data/lib/generators/templates/common/partials/mobile_config.tt +5 -0
  31. data/lib/generators/templates/common/partials/web_config.tt +9 -1
  32. data/lib/generators/templates/common/read_me.tt +84 -2
  33. data/lib/generators/templates/helpers/browser_helper.tt +5 -1
  34. data/lib/generators/templates/helpers/driver_helper.tt +1 -0
  35. data/lib/generators/templates/helpers/partials/selenium_driver.tt +4 -1
  36. data/lib/generators/templates/helpers/session_state.tt +206 -0
  37. data/lib/generators/templates/helpers/spec_helper.tt +28 -0
  38. data/lib/generators/templates/helpers/video_helper.tt +338 -171
  39. data/lib/ruby_raider/ffmpeg.rb +280 -0
  40. data/lib/ruby_raider.rb +2 -3
  41. data/lib/scaffolding/project_detector.rb +1 -3
  42. data/lib/scaffolding/scaffolding.rb +0 -1
  43. data/lib/utilities/utilities.rb +15 -2
  44. data/lib/version +1 -1
  45. data/ruby_raider.gemspec +4 -1
  46. data/sig/commands/utility_commands.rbs +4 -1
  47. data/sig/generators/invoke_generators.rbs +2 -3
  48. data/sig/ruby_raider/ffmpeg.rbs +23 -0
  49. data/sig/utilities/utilities.rbs +1 -0
  50. data/spec/commands/raider_commands_spec.rb +56 -1
  51. data/spec/generators/fixtures/video_helper_stubs/selenium-webdriver.rb +28 -0
  52. data/spec/generators/video_helper_spec.rb +608 -0
  53. data/spec/integration/commands/browser_update_after_creation_spec.rb +7 -6
  54. data/spec/integration/commands/utility_commands_spec.rb +2 -2
  55. data/spec/integration/content/azure_content_spec.rb +33 -0
  56. data/spec/integration/content/ci_content_spec.rb +54 -0
  57. data/spec/integration/content/common_content_spec.rb +35 -0
  58. data/spec/integration/content/config_content_spec.rb +32 -0
  59. data/spec/integration/content/cucumber_reporter_content_spec.rb +47 -1
  60. data/spec/integration/content/gemfile_content_spec.rb +2 -3
  61. data/spec/integration/content/helper_content_spec.rb +178 -0
  62. data/spec/integration/content/page_content_spec.rb +0 -1
  63. data/spec/integration/content/readme_content_spec.rb +79 -0
  64. data/spec/integration/content/rspec_reporter_content_spec.rb +2 -2
  65. data/spec/integration/content/syntax_validation_spec.rb +15 -0
  66. data/spec/integration/content/test_content_spec.rb +27 -3
  67. data/spec/integration/end_to_end_features_spec.rb +0 -2
  68. data/spec/integration/end_to_end_spec.rb +275 -26
  69. data/spec/integration/generators/azure_generator_spec.rb +7 -6
  70. data/spec/integration/generators/common_generator_spec.rb +1 -1
  71. data/spec/integration/generators/helpers_generator_spec.rb +22 -0
  72. data/spec/integration/generators/invoke_generators_spec.rb +52 -0
  73. data/spec/integration/helpers/session_state_spec.rb +428 -0
  74. data/spec/integration/scaffolding_e2e_spec.rb +13 -24
  75. data/spec/scaffolding/scaffold_project_detector_spec.rb +0 -1
  76. data/spec/scaffolding/scaffolding_features_spec.rb +0 -1
  77. data/spec/system/selenium_spec.rb +0 -1
  78. data/spec/system/watir_spec.rb +0 -1
  79. data/spec/utilities/ffmpeg_spec.rb +424 -0
  80. data/spec/utilities/utilities_spec.rb +19 -2
  81. data/spec/utilities/video_command_spec.rb +120 -0
  82. metadata +65 -11
  83. data/lib/commands/adopt_commands.rb +0 -42
  84. data/lib/commands/loaded_commands.rb +0 -9
  85. data/lib/commands/plugin_commands.rb +0 -32
  86. data/sig/commands/adopt_commands.rbs +0 -8
  87. data/sig/commands/loaded_commands.rbs +0 -5
  88. data/sig/commands/plugin_commands.rbs +0 -9
@@ -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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../generator'
4
+ require_relative '../../ruby_raider/ffmpeg'
4
5
 
5
6
  class AzureGenerator < Generator
6
7
  def generate_azure_pipeline
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../generator'
4
+ require_relative '../../ruby_raider/ffmpeg'
4
5
 
5
6
  class GithubGenerator < Generator
6
7
  def generate_actions_file
@@ -12,7 +12,9 @@ pool:
12
12
 
13
13
  variables:
14
14
  HEADLESS: 'true'
15
- # Add RAIDER_TOKEN (secret) and RAIDER_ENDPOINT as pipeline variables.
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
- class InvalidParameters < ArgumentError; end
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 InvalidParameters, 'ci must be github or azure'
54
+ raise Thor::Error, 'ci must be github or azure'
37
55
  end
38
56
 
39
57
  def print_mobile_azure_notice(structure)
@@ -20,5 +20,4 @@ class RspecGenerator < Generator
20
20
 
21
21
  template('accessibility_spec.tt', "#{name}/spec/accessibility_spec.rb")
22
22
  end
23
-
24
23
  end
@@ -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,7 +13,7 @@ gem 'parallel_split_test'
13
13
  gem 'parallel_tests'
14
14
  gem 'rake'
15
15
  gem 'reek'
16
- gem 'raider_reporter', '~> 0.9'
16
+ gem 'raider_reporter', '~> 0.10'
17
17
  <%- if rspec? -%>
18
18
  gem 'rspec-retry'
19
19
  <%- end -%>
@@ -1,4 +1,10 @@
1
1
  raider-results
2
2
  <%- if rspec? -%>
3
3
  spec/examples.txt
4
- <%- end -%>
4
+ <%- end -%>
5
+ <%- if cucumber? -%>
6
+ rerun.txt
7
+ <%- end -%>
8
+ <%- if web? -%>
9
+ .raider/auth/
10
+ <%- end -%>
@@ -1 +1,6 @@
1
1
  platform: :ios
2
+
3
+ video:
4
+ enabled: true
5
+ fps: 5
6
+ max_width: 1280
@@ -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://your-raider-endpoint
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
- Watir::Browser.new(browser_name.to_sym, options: browser_options)
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
@@ -4,6 +4,7 @@ require 'yaml'
4
4
  <%- if selenium_based? -%>
5
5
  require 'active_support/inflector'
6
6
  require 'selenium-webdriver'
7
+ require_relative 'video_helper'
7
8
  <%- else -%>
8
9
  require 'appium_lib'
9
10
  <%- 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
- driver = Selenium::WebDriver.for(browser.to_sym, options: create_webdriver_options(*opts))
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