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
@@ -10,6 +10,18 @@ describe HelpersGenerator do
10
10
  end
11
11
  end
12
12
 
13
+ shared_examples 'creates a session state helper' do |name|
14
+ it 'creates a session state helper file' do
15
+ expect(File).to exist("#{name}/helpers/session_state.rb")
16
+ end
17
+ end
18
+
19
+ shared_examples 'skips the session state helper' do |name|
20
+ it 'does not create a session state helper file' do
21
+ expect(File).not_to exist("#{name}/helpers/session_state.rb")
22
+ end
23
+ end
24
+
13
25
  shared_examples 'creates selenium helpers' do |name|
14
26
  it 'creates a driver helper file' do
15
27
  expect(File).to exist("#{name}/helpers/driver_helper.rb")
@@ -42,54 +54,63 @@ describe HelpersGenerator do
42
54
 
43
55
  context 'with rspec and selenium' do
44
56
  include_examples 'creates common helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::SELENIUM}"
57
+ include_examples 'creates a session state helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::SELENIUM}"
45
58
  include_examples 'creates selenium helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::SELENIUM}"
46
59
  include_examples 'creates rspec helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::SELENIUM}"
47
60
  end
48
61
 
49
62
  context 'with rspec and watir' do
50
63
  include_examples 'creates common helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::WATIR}"
64
+ include_examples 'creates a session state helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::WATIR}"
51
65
  include_examples 'creates watir helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::WATIR}"
52
66
  include_examples 'creates rspec helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::WATIR}"
53
67
  end
54
68
 
55
69
  context 'with cucumber and selenium' do
56
70
  include_examples 'creates common helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::SELENIUM}"
71
+ include_examples 'creates a session state helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::SELENIUM}"
57
72
  include_examples 'creates selenium helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::SELENIUM}"
58
73
  include_examples 'creates cucumber helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::SELENIUM}"
59
74
  end
60
75
 
61
76
  context 'with cucumber and watir' do
62
77
  include_examples 'creates common helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::WATIR}"
78
+ include_examples 'creates a session state helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::WATIR}"
63
79
  include_examples 'creates watir helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::WATIR}"
64
80
  include_examples 'creates cucumber helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::WATIR}"
65
81
  end
66
82
 
67
83
  context 'with rspec and appium android' do
68
84
  include_examples 'creates common helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::ANDROID}"
85
+ include_examples 'skips the session state helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::ANDROID}"
69
86
  include_examples 'creates selenium helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::ANDROID}"
70
87
  include_examples 'creates rspec helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::ANDROID}"
71
88
  end
72
89
 
73
90
  context 'with rspec and appium ios' do
74
91
  include_examples 'creates common helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::IOS}"
92
+ include_examples 'skips the session state helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::IOS}"
75
93
  include_examples 'creates selenium helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::IOS}"
76
94
  include_examples 'creates rspec helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::IOS}"
77
95
  end
78
96
 
79
97
  context 'with cucumber and appium android' do
80
98
  include_examples 'creates common helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::ANDROID}"
99
+ include_examples 'skips the session state helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::ANDROID}"
81
100
  include_examples 'creates selenium helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::ANDROID}"
82
101
  include_examples 'creates cucumber helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::ANDROID}"
83
102
  end
84
103
 
85
104
  context 'with cucumber and appium ios' do
86
105
  include_examples 'creates common helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::IOS}"
106
+ include_examples 'skips the session state helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::IOS}"
87
107
  include_examples 'creates selenium helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::IOS}"
88
108
  include_examples 'creates cucumber helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::IOS}"
89
109
  end
90
110
 
91
111
  context 'with rspec and appium cross platform' do
92
112
  include_examples 'creates common helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::CROSS_PLATFORM}"
113
+ include_examples 'skips the session state helper', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::CROSS_PLATFORM}"
93
114
  include_examples 'creates selenium helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::CROSS_PLATFORM}"
94
115
  include_examples 'creates rspec helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::CROSS_PLATFORM}"
95
116
  include_examples 'creates cross platform helpers', "#{FrameworkIndex::RSPEC}_#{AutomationIndex::CROSS_PLATFORM}"
@@ -97,6 +118,7 @@ describe HelpersGenerator do
97
118
 
98
119
  context 'with cucumber and appium cross platform' do
99
120
  include_examples 'creates common helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::CROSS_PLATFORM}"
121
+ include_examples 'skips the session state helper', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::CROSS_PLATFORM}"
100
122
  include_examples 'creates selenium helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::CROSS_PLATFORM}"
101
123
  include_examples 'creates cucumber helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::CROSS_PLATFORM}"
102
124
  include_examples 'creates cross platform helpers', "#{FrameworkIndex::CUCUMBER}_#{AutomationIndex::CROSS_PLATFORM}"
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'open3'
5
+ require_relative '../spec_helper'
6
+
7
+ describe InvokeGenerators do
8
+ after { FileUtils.rm_rf(Dir.glob('case_*')) }
9
+
10
+ context 'with option values in a different case' do
11
+ it 'accepts framework:Cucumber and automation:Selenium' do
12
+ described_class.generate_framework(framework: 'Cucumber', automation: 'Selenium', name: 'case_mixed')
13
+ expect(File).to exist('case_mixed/cucumber.yml')
14
+ end
15
+
16
+ it 'accepts upper-case values' do
17
+ described_class.generate_framework(framework: 'RSPEC', automation: 'WATIR', name: 'case_upper')
18
+ expect(File).to exist('case_upper/.rspec')
19
+ end
20
+
21
+ it 'accepts symbols' do
22
+ described_class.generate_framework(framework: :rspec, automation: :selenium, name: 'case_symbol')
23
+ expect(File).to exist('case_symbol/.rspec')
24
+ end
25
+ end
26
+
27
+ context 'with raider new and capitalised options' do
28
+ it 'generates the project and exits zero' do
29
+ root = File.expand_path('../../..', __dir__)
30
+ _out, status = Open3.capture2e('ruby', "#{root}/bin/raider", 'new', 'case_cli', '-p', 'framework:Cucumber',
31
+ 'automation:Selenium')
32
+ expect(status.exitstatus).to eq(0)
33
+ expect(File).to exist('case_cli/cucumber.yml')
34
+ end
35
+ end
36
+
37
+ context 'with invalid values' do
38
+ it 'raises a Thor::Error for an unknown framework' do
39
+ expect { described_class.generate_framework(framework: 'minitest', automation: 'selenium', name: 'case_bad') }
40
+ .to raise_error(Thor::Error, /Invalid framework "minitest"/)
41
+ end
42
+
43
+ it 'sends an invalid ci to stderr and exits non-zero' do
44
+ root = File.expand_path('../../..', __dir__)
45
+ _out, err, status = Open3.capture3('ruby', "#{root}/bin/raider", 'new', 'case_bad_ci', '-p',
46
+ 'framework:rspec', 'automation:selenium', 'ci:gitlab')
47
+ expect(status.exitstatus).to eq(1)
48
+ expect(err).to include('ci must be github or azure')
49
+ expect(File).not_to exist('case_bad_ci')
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,428 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'json'
5
+ require 'selenium-webdriver'
6
+ require 'time'
7
+ require 'tmpdir'
8
+ require 'yaml'
9
+
10
+ module SessionStateSpec
11
+ TEMPLATE = File.expand_path('../../../lib/generators/templates/helpers/session_state.tt', __dir__)
12
+
13
+ # Renders the template and loads it from a temp file that is removed straight away.
14
+ def self.load_template
15
+ Dir.mktmpdir('session_state_tt') do |dir|
16
+ file = File.join(dir, 'session_state.rb')
17
+ File.write(file, ERB.new(File.read(TEMPLATE), trim_mode: '-').result)
18
+ load file
19
+ end
20
+ end
21
+ end
22
+ SessionStateSpec.load_template
23
+
24
+ module SessionStateSpec
25
+ # Implements exactly the driver calls SessionState uses and records them in order.
26
+ class FakeDriver
27
+ ORIGIN_URL = 'https://raider-test-site.onrender.com/account'
28
+
29
+ def initialize(cookies: [], local: {}, session: {}, reject: nil, url: ORIGIN_URL)
30
+ @reject = reject
31
+ @page = { cookies:, url: }
32
+ @storage = { 'localStorage' => local, 'sessionStorage' => session }
33
+ @log = { calls: [], added: [] }
34
+ end
35
+
36
+ def calls = @log[:calls]
37
+ def added = @log[:added]
38
+ def local = @storage['localStorage']
39
+ def session = @storage['sessionStorage']
40
+
41
+ def manage = self
42
+ def navigate = self
43
+ def all_cookies = @page[:cookies]
44
+ def current_url = @page[:url]
45
+ def refresh = calls << :refresh
46
+ def delete_all_cookies = calls << :delete_all_cookies
47
+
48
+ def add_cookie(cookie)
49
+ raise 'rejected' if @reject&.call(cookie)
50
+
51
+ calls << :add_cookie
52
+ added << cookie
53
+ end
54
+
55
+ def to(url)
56
+ @page[:url] = url
57
+ calls << [:to, url]
58
+ end
59
+
60
+ def execute_script(_script, *args)
61
+ kind, values = args
62
+ return @storage[kind] if values.nil?
63
+
64
+ calls << [:write, kind]
65
+ @storage[kind] = values
66
+ end
67
+ end
68
+
69
+ # The browser end of a real Selenium::WebDriver::Manager: records the wire payloads it receives.
70
+ class FakeBridge
71
+ def initialize(log, reject)
72
+ @log = log
73
+ @reject = reject
74
+ end
75
+
76
+ def add_cookie(cookie)
77
+ raise Selenium::WebDriver::Error::InvalidArgumentError, 'rejected' if @reject&.call(cookie)
78
+
79
+ @log[:calls] << :add_cookie
80
+ @log[:added] << cookie
81
+ end
82
+
83
+ def delete_all_cookies = @log[:calls] << :delete_all_cookies
84
+ def cookies = []
85
+ end
86
+
87
+ # A FakeDriver whose manage is the real Selenium Manager, so its key translation is under test.
88
+ class ManagedDriver < FakeDriver
89
+ def manage
90
+ @manage ||= Selenium::WebDriver::Manager.new(FakeBridge.new(@log, @reject))
91
+ end
92
+ end
93
+ end
94
+
95
+ describe 'SessionState' do
96
+ let(:url) { 'https://raider-test-site.onrender.com/' }
97
+ let(:driver) { SessionStateSpec::FakeDriver.new(cookies:, local: { 'token' => 'abc' }, session: { 'tab' => '1' }) }
98
+ let(:state) { SessionState.new(driver) }
99
+ let(:config) { { 'url' => url } }
100
+
101
+ def origin = 'https://raider-test-site.onrender.com'
102
+ def state_file = File.join('.raider', 'auth', 'user.json')
103
+
104
+ def cookies
105
+ [{ name: 'sid', value: 's3cr3t', domain: 'raider-test-site.onrender.com', path: '/', expires: nil,
106
+ secure: true, http_only: true, same_site: 'Lax' }]
107
+ end
108
+
109
+ around do |example|
110
+ Dir.mktmpdir do |dir|
111
+ Dir.chdir(dir) do
112
+ FileUtils.mkdir_p('config')
113
+ example.run
114
+ end
115
+ end
116
+ end
117
+
118
+ before do
119
+ File.write('config/config.yml', YAML.dump(config))
120
+ stub_const('ENV', ENV.to_h.merge('RAIDER_LOGIN_STATE' => nil))
121
+ end
122
+
123
+ def write_state(overrides = {})
124
+ FileUtils.mkdir_p(File.dirname(state_file))
125
+ data = { 'format' => 1, 'name' => 'user', 'origin' => origin, 'saved_at' => (Time.now.utc - 10).iso8601,
126
+ 'cookies' => [], 'local_storage' => {}, 'session_storage' => {} }.merge(overrides)
127
+ File.write(state_file, JSON.generate(data))
128
+ end
129
+
130
+ def saved
131
+ JSON.parse(File.read(state_file))
132
+ end
133
+
134
+ def saved_cookie(name, **attrs)
135
+ { 'name' => name, 'value' => 'v', 'domain' => 'raider-test-site.onrender.com', 'path' => '/',
136
+ 'expires' => nil, 'secure' => false, 'http_only' => false, 'same_site' => nil }.merge(attrs.transform_keys(&:to_s))
137
+ end
138
+
139
+ it 'rejects a name outside [a-z0-9_-]' do
140
+ expect { SessionState.new(driver, name: '../x') }.to raise_error(ArgumentError)
141
+ expect { SessionState.new(driver, name: 'Admin') }.to raise_error(ArgumentError)
142
+ end
143
+
144
+ it 'saves format 1 with origin, saved_at, cookies and both storages' do
145
+ state.save
146
+ expect(saved).to include('format' => 1, 'name' => 'user', 'origin' => origin,
147
+ 'local_storage' => { 'token' => 'abc' }, 'session_storage' => { 'tab' => '1' })
148
+ expect { Time.iso8601(saved['saved_at']) }.not_to raise_error
149
+ expect(saved['cookies'].first).to include('name' => 'sid', 'http_only' => true, 'secure' => true)
150
+ end
151
+
152
+ it 'writes the file 0600 in a 0700 directory, atomically' do
153
+ state.save
154
+ expect(File.stat(state_file).mode & 0o777).to eq(0o600)
155
+ expect(File.stat(File.dirname(state_file)).mode & 0o777).to eq(0o700)
156
+ expect(Dir.glob('.raider/auth/*.tmp')).to be_empty
157
+ end
158
+
159
+ context 'with a url that has a port and a path' do
160
+ let(:url) { 'https://x.test:8443/app/' }
161
+
162
+ it 'derives the origin without path or trailing slash' do
163
+ state.save
164
+ expect(saved['origin']).to eq('https://x.test:8443')
165
+ end
166
+ end
167
+
168
+ describe '#save on another origin' do
169
+ it 'goes back to the origin before reading when the login ended elsewhere' do
170
+ sso = SessionStateSpec::FakeDriver.new(url: 'https://sso.other.test/x')
171
+ SessionState.new(sso).save
172
+ expect(sso.calls).to eq([[:to, "#{origin}/"]])
173
+ end
174
+
175
+ it 'stays put when the login ended on the origin' do
176
+ state.save
177
+ expect(driver.calls).to be_empty
178
+ end
179
+ end
180
+
181
+ describe '#fresh?' do
182
+ it 'is false when the file is missing' do
183
+ expect(state.fresh?).to be(false)
184
+ end
185
+
186
+ it 'is true for a recent file of the same format and origin' do
187
+ write_state
188
+ expect(state.fresh?).to be(true)
189
+ end
190
+
191
+ it 'is false for another format, another origin or an old file' do
192
+ write_state('format' => 2)
193
+ expect(state.fresh?).to be(false)
194
+ write_state('origin' => 'https://other.test')
195
+ expect(state.fresh?).to be(false)
196
+ write_state('saved_at' => (Time.now.utc - 3601).iso8601)
197
+ expect(state.fresh?).to be(false)
198
+ end
199
+
200
+ context 'with login_state max_age 30' do
201
+ let(:config) { { 'url' => url, 'login_state' => { 'max_age' => 30 } } }
202
+
203
+ it 'is false for a file older than max_age' do
204
+ write_state('saved_at' => (Time.now.utc - 59).iso8601)
205
+ expect(state.fresh?).to be(false)
206
+ end
207
+ end
208
+ end
209
+
210
+ describe '#restore' do
211
+ let(:driver) { SessionStateSpec::ManagedDriver.new }
212
+
213
+ it 'restores live cookies for the origin and skips expired and foreign ones' do
214
+ write_state('cookies' => [
215
+ saved_cookie('session'),
216
+ saved_cookie('future', expires: (Time.now.utc + 86_400).iso8601),
217
+ saved_cookie('past', expires: (Time.now.utc - 1).iso8601),
218
+ saved_cookie('dotted', domain: '.raider-test-site.onrender.com'),
219
+ saved_cookie('foreign', domain: 'other.com')
220
+ ], 'local_storage' => { 'a' => 'b' })
221
+ warning = "[login_state] 2 cookies skipped (expired or another domain) from #{state_file}\n"
222
+ expect { expect(state.restore).to be(true) }.to output(warning).to_stderr
223
+ expect(driver.added.map { |c| c[:name] }).to eq(%w[session future dotted])
224
+ expect(driver.calls).to eq([[:to, "#{origin}/"], :delete_all_cookies, :add_cookie, :add_cookie, :add_cookie,
225
+ [:write, 'localStorage'], [:write, 'sessionStorage'], :refresh])
226
+ end
227
+
228
+ it 'hands Selenium the cookie keys it translates to the wire format' do
229
+ write_state('cookies' => [saved_cookie('session', same_site: 'Lax', http_only: true),
230
+ saved_cookie('future', expires: (Time.now.utc + 86_400).iso8601)])
231
+ expect(state.restore).to be(true)
232
+ expect(driver.added.first).to include(sameSite: 'Lax', httpOnly: true, secure: false)
233
+ expect(driver.added.flat_map(&:keys)).not_to include(:same_site, :http_only, :expires)
234
+ expect(driver.added.last[:expiry]).to be_a(Integer)
235
+ end
236
+
237
+ it 'returns false and warns for corrupt JSON' do
238
+ FileUtils.mkdir_p(File.dirname(state_file))
239
+ File.write(state_file, '{"format":')
240
+ expect { expect(state.restore).to be(false) }.to output(/\[login_state\].*user\.json/).to_stderr
241
+ end
242
+ end
243
+
244
+ it 'never prints cookie or storage values' do
245
+ leak = /s3cr3t|abc/
246
+ expect do
247
+ expect do
248
+ state.save
249
+ state.restore
250
+ write_state('cookies' => [saved_cookie('x', value: 's3cr3t')], 'local_storage' => { 'k' => 'abc' })
251
+ state.reuse(valid: -> { false }) { nil }
252
+ end.not_to output(leak).to_stdout
253
+ end.not_to output(leak).to_stderr
254
+ end
255
+
256
+ describe 'unsupported or unusable files' do
257
+ it 'warns once, naming the file, for another format' do
258
+ write_state('format' => 2)
259
+ expect { expect(state.restore).to be(false) }.to output(/\A\[login_state\] #{Regexp.escape(state_file)} has an unsupported format.*\n\z/).to_stderr
260
+ end
261
+
262
+ it 'stays silent for a missing, expired or other-origin file' do
263
+ expect do
264
+ state.fresh?
265
+ write_state('origin' => 'https://other.test')
266
+ state.fresh?
267
+ write_state('saved_at' => (Time.now.utc - 7200).iso8601)
268
+ state.fresh?
269
+ end.not_to output.to_stderr
270
+ end
271
+
272
+ it 'retries a cookie the browser rejects without sameSite' do
273
+ rejecting = SessionStateSpec::ManagedDriver.new(reject: ->(cookie) { cookie.key?(:sameSite) })
274
+ write_state('cookies' => [saved_cookie('a', same_site: 'None')])
275
+ expect(SessionState.new(rejecting).restore).to be(true)
276
+ expect(rejecting.added.map { |c| [c[:name], c.key?(:sameSite)] }).to eq([['a', false]])
277
+ end
278
+ end
279
+
280
+ describe '#reuse' do
281
+ def run_reuse(**options)
282
+ count = 0
283
+ silence { state.reuse(**options) { count += 1 } }
284
+ count
285
+ end
286
+
287
+ def silence
288
+ original = $stderr
289
+ $stderr = StringIO.new
290
+ yield
291
+ ensure
292
+ $stderr = original
293
+ end
294
+
295
+ def warnings
296
+ original = $stderr
297
+ $stderr = StringIO.new
298
+ yield
299
+ $stderr.string
300
+ ensure
301
+ $stderr = original
302
+ end
303
+
304
+ it 'logs in and saves when there is no file' do
305
+ expect(run_reuse).to eq(1)
306
+ expect(File).to exist(state_file)
307
+ expect(driver.calls).not_to include(:delete_all_cookies)
308
+ end
309
+
310
+ it 'clears the restored cookies and storage before the fallback login' do
311
+ managed = SessionStateSpec::ManagedDriver.new
312
+ write_state('cookies' => [saved_cookie('a')], 'local_storage' => { 'k' => 'v' })
313
+ silence { SessionState.new(managed).reuse(valid: -> { false }) { managed.calls << :login } }
314
+ after_refresh = managed.calls.drop(managed.calls.index(:refresh) + 1)
315
+ expect(after_refresh).to eq([:delete_all_cookies, [:write, 'localStorage'], [:write, 'sessionStorage'], :login])
316
+ expect(managed.local).to eq({})
317
+ end
318
+
319
+ it 'clears a half-applied restore before the fallback login' do
320
+ broken = SessionStateSpec::ManagedDriver.new(reject: ->(_cookie) { true })
321
+ write_state('cookies' => [saved_cookie('a')])
322
+ silence { SessionState.new(broken).reuse { broken.calls << :login } }
323
+ expect(broken.calls.last(4)).to eq([:delete_all_cookies, [:write, 'localStorage'], [:write, 'sessionStorage'],
324
+ :login])
325
+ end
326
+
327
+ it 'logs in without saving, with one warning, when the auth directory cannot be created' do
328
+ FileUtils.mkdir_p('.raider')
329
+ File.chmod(0o500, '.raider')
330
+ count = 0
331
+ output = warnings { expect(state.reuse { count += 1 }).to be(true) }
332
+ expect(count).to eq(1)
333
+ expect(output.lines).to match([start_with('[login_state] ')])
334
+ ensure
335
+ File.chmod(0o700, '.raider')
336
+ end
337
+
338
+ it 'logs in without saving, with one warning, when the lock cannot be opened' do
339
+ FileUtils.mkdir_p(File.join('.raider', 'auth', 'user.lock'))
340
+ count = 0
341
+ output = warnings { expect(state.reuse { count += 1 }).to be(true) }
342
+ expect(count).to eq(1)
343
+ expect(output.lines).to match([start_with('[login_state] ')])
344
+ expect(File).not_to exist(state_file)
345
+ end
346
+
347
+ it 'keeps the login, with one warning naming the file, when saving after it fails' do
348
+ allow(driver).to receive(:execute_script).and_raise(Selenium::WebDriver::Error::JavascriptError, 'abc')
349
+ count = 0
350
+ output = warnings { expect(state.reuse { count += 1 }).to be(true) }
351
+ expect(count).to eq(1)
352
+ expect(output.lines).to match([include(state_file, 'JavascriptError')])
353
+ expect(output).not_to include('abc')
354
+ end
355
+
356
+ it 'restores and skips the login when fresh and valid' do
357
+ write_state
358
+ expect(run_reuse(valid: -> { true })).to eq(0)
359
+ expect(driver.calls).to include(:refresh)
360
+ end
361
+
362
+ it 'falls back once when valid returns false' do
363
+ write_state('saved_at' => (Time.now.utc - 100).iso8601)
364
+ before = Time.iso8601(saved['saved_at'])
365
+ expect(run_reuse(valid: -> { false })).to eq(1)
366
+ expect(Time.iso8601(saved['saved_at'])).to be > before
367
+ end
368
+
369
+ it 'falls back when the file is corrupt' do
370
+ FileUtils.mkdir_p(File.dirname(state_file))
371
+ File.write(state_file, '{"format":')
372
+ expect(run_reuse).to eq(1)
373
+ expect(saved['format']).to eq(1)
374
+ end
375
+
376
+ it 'falls back once, with one warning, when valid raises' do
377
+ write_state
378
+ count = 0
379
+ expect { state.reuse(valid: -> { raise 'boom' }) { count += 1 } }.to output(/\A\[login_state\] validity check failed \(RuntimeError\), logging in again\n\z/).to_stderr
380
+ expect(count).to eq(1)
381
+ end
382
+
383
+ it 'falls back once, with one warning, when restore raises' do
384
+ write_state('cookies' => [saved_cookie('a')])
385
+ broken = SessionStateSpec::FakeDriver.new(reject: ->(_cookie) { true })
386
+ count = 0
387
+ expect { SessionState.new(broken).reuse { count += 1 } }.to output(/\A\[login_state\] could not restore.*\n\z/).to_stderr
388
+ expect(count).to eq(1)
389
+ end
390
+
391
+ it 'warns once for another format and logs in' do
392
+ write_state('format' => 2)
393
+ count = 0
394
+ expect { state.reuse { count += 1 } }.to output(/\A\[login_state\] .*unsupported format.*\n\z/).to_stderr
395
+ expect(count).to eq(1)
396
+ end
397
+
398
+ it 'RAIDER_LOGIN_STATE=off logs in and does not touch the disk' do
399
+ stub_const('ENV', ENV.to_h.merge('RAIDER_LOGIN_STATE' => 'off'))
400
+ expect(run_reuse).to eq(1)
401
+ expect(File).not_to exist('.raider')
402
+ end
403
+
404
+ it 'RAIDER_LOGIN_STATE=refresh logs in even when fresh' do
405
+ write_state('saved_at' => (Time.now.utc - 100).iso8601)
406
+ before = Time.iso8601(saved['saved_at'])
407
+ stub_const('ENV', ENV.to_h.merge('RAIDER_LOGIN_STATE' => 'refresh'))
408
+ expect(run_reuse).to eq(1)
409
+ expect(Time.iso8601(saved['saved_at'])).to be > before
410
+ end
411
+
412
+ it 'serializes concurrent callers on the lock' do
413
+ count = 0
414
+ drivers = Array.new(2) { SessionStateSpec::FakeDriver.new(cookies:) }
415
+ threads = drivers.map do |fake|
416
+ Thread.new do
417
+ SessionState.new(fake).reuse do
418
+ sleep 0.2
419
+ count += 1
420
+ end
421
+ end
422
+ end
423
+ threads.each(&:join)
424
+ expect(count).to eq(1)
425
+ expect(drivers.count { |fake| fake.calls.include?(:refresh) }).to eq(1)
426
+ end
427
+ end
428
+ end
@@ -23,11 +23,7 @@ end
23
23
 
24
24
  # End-to-end scaffolding tests that verify the scaffold command creates the right
25
25
  # files with the right content across all automation × framework combinations.
26
- describe 'Scaffolding E2E' do # rubocop:disable RSpec/DescribeClass
27
-
28
- WEB_AUTOMATIONS = %w[selenium watir].freeze
29
- TEST_FRAMEWORKS = %w[rspec cucumber].freeze
30
-
26
+ describe 'Scaffolding E2E' do
31
27
  def self.scaffold_style(framework)
32
28
  case framework
33
29
  when 'rspec' then :spec
@@ -36,7 +32,7 @@ describe 'Scaffolding E2E' do # rubocop:disable RSpec/DescribeClass
36
32
  end
37
33
 
38
34
  # Build a minimal mock project that the scaffolding system can detect.
39
- def self.build_mock_project(automation, framework) # rubocop:disable Metrics/MethodLength
35
+ def self.build_mock_project(automation, framework)
40
36
  dir = File.expand_path("tmp_scaffold_e2e_#{framework}_#{automation}")
41
37
  FileUtils.rm_rf(dir)
42
38
  FileUtils.mkdir_p("#{dir}/page_objects/pages")
@@ -61,7 +57,7 @@ describe 'Scaffolding E2E' do # rubocop:disable RSpec/DescribeClass
61
57
  when 'rspec' then gems << "gem 'rspec'"
62
58
  when 'cucumber' then gems << "gem 'cucumber'"
63
59
  end
64
- File.write("#{dir}/Gemfile", gems.join("\n") + "\n")
60
+ File.write("#{dir}/Gemfile", "#{gems.join("\n")}\n")
65
61
  File.write("#{dir}/config/config.yml", "browser: chrome\nurl: http://localhost:3000\n")
66
62
  File.write("#{dir}/page_objects/abstract/page.rb", "class Page; end\n")
67
63
  File.write("#{dir}/page_objects/abstract/component.rb", "class Component; end\n")
@@ -91,7 +87,7 @@ describe 'Scaffolding E2E' do # rubocop:disable RSpec/DescribeClass
91
87
  end
92
88
 
93
89
  it 'includes url method (config has url)' do
94
- expect(page_content).to include("def url(_page)")
90
+ expect(page_content).to include('def url(_page)')
95
91
  expect(page_content).to include("'checkout'")
96
92
  end
97
93
  end
@@ -326,11 +322,11 @@ describe 'Scaffolding E2E' do # rubocop:disable RSpec/DescribeClass
326
322
  # Test matrix: automation × framework
327
323
  # ══════════════════════════════════════════════
328
324
 
329
- WEB_AUTOMATIONS.each do |automation|
330
- TEST_FRAMEWORKS.each do |framework|
325
+ %w[selenium watir].each do |automation|
326
+ %w[rspec cucumber].each do |framework|
331
327
  style = scaffold_style(framework)
332
328
 
333
- context "with #{framework} and #{automation}" do # rubocop:disable RSpec/ContextWording
329
+ context "with #{framework} and #{automation}" do
334
330
  let(:scaffold) { ScaffoldingCommands }
335
331
 
336
332
  # Use absolute path so Dir.chdir is safe regardless of current CWD
@@ -367,6 +363,9 @@ describe 'Scaffolding E2E' do # rubocop:disable RSpec/DescribeClass
367
363
  end
368
364
  end
369
365
 
366
+ # Page content validation
367
+ let(:page_content) { File.read('page_objects/pages/checkout.rb') }
368
+
370
369
  it 'creates page object' do
371
370
  expect(Pathname.new('page_objects/pages/checkout.rb')).to be_file
372
371
  end
@@ -389,9 +388,6 @@ describe 'Scaffolding E2E' do # rubocop:disable RSpec/DescribeClass
389
388
  end
390
389
  end
391
390
 
392
- # Page content validation
393
- let(:page_content) { File.read('page_objects/pages/checkout.rb') }
394
-
395
391
  include_examples 'valid scaffolded page'
396
392
  include_examples "#{automation} scaffolded page"
397
393
 
@@ -459,16 +455,10 @@ describe 'Scaffolding E2E' do # rubocop:disable RSpec/DescribeClass
459
455
 
460
456
  it 'generates multiple scaffolds at once' do
461
457
  scaffold.new.invoke(:scaffold, nil, %w[search filter])
462
- expect(Pathname.new('page_objects/pages/search.rb')).to be_file
463
- expect(Pathname.new('page_objects/pages/filter.rb')).to be_file
464
-
465
- if style == :spec
466
- expect(Pathname.new('spec/search_page_spec.rb')).to be_file
467
- expect(Pathname.new('spec/filter_page_spec.rb')).to be_file
468
- else
469
- expect(Pathname.new('features/search.feature')).to be_file
470
- expect(Pathname.new('features/filter.feature')).to be_file
458
+ files = %w[search filter].flat_map do |name|
459
+ ["page_objects/pages/#{name}.rb", style == :spec ? "spec/#{name}_page_spec.rb" : "features/#{name}.feature"]
471
460
  end
461
+ expect(files.reject { |file| File.file?(file) }).to be_empty
472
462
  end
473
463
  end
474
464
 
@@ -496,7 +486,6 @@ describe 'Scaffolding E2E' do # rubocop:disable RSpec/DescribeClass
496
486
  expect(Pathname.new('page_objects/pages/checkout.rb')).to be_file
497
487
  end
498
488
  end
499
-
500
489
  end
501
490
  end
502
491
  end
@@ -33,7 +33,6 @@ RSpec.describe ScaffoldProjectDetector do
33
33
  it 'reports selenium?' do
34
34
  expect(described_class.selenium?).to be true
35
35
  end
36
-
37
36
  end
38
37
 
39
38
  context 'with watir project' do