ruby_raider 3.10.0 → 3.12.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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/reek.yml +5 -1
  3. data/.github/workflows/release.yml +2 -1
  4. data/.github/workflows/system_tests.yml +1 -1
  5. data/.reek.yml +13 -0
  6. data/README.md +44 -4
  7. data/lib/commands/utility_commands.rb +1 -1
  8. data/lib/generators/automation/automation_generator.rb +15 -10
  9. data/lib/generators/automation/templates/appium_caps.tt +2 -4
  10. data/lib/generators/automation/templates/home.tt +1 -2
  11. data/lib/generators/automation/templates/login.tt +6 -6
  12. data/lib/generators/automation/templates/page.tt +10 -3
  13. data/lib/generators/automation/templates/partials/home_page_selector.tt +4 -6
  14. data/lib/generators/automation/templates/partials/pdp_page_selector.tt +4 -6
  15. data/lib/generators/automation/templates/pdp.tt +3 -2
  16. data/lib/generators/cucumber/cucumber_generator.rb +15 -2
  17. data/lib/generators/cucumber/templates/feature.tt +5 -0
  18. data/lib/generators/cucumber/templates/partials/driver_world.tt +9 -0
  19. data/lib/generators/cucumber/templates/partials/mobile_steps.tt +1 -1
  20. data/lib/generators/cucumber/templates/partials/selenium_env.tt +20 -0
  21. data/lib/generators/cucumber/templates/partials/watir_env.tt +16 -0
  22. data/lib/generators/cucumber/templates/partials/web_steps.tt +8 -0
  23. data/lib/generators/generator.rb +15 -4
  24. data/lib/generators/helper_generator.rb +15 -0
  25. data/lib/generators/invoke_generators.rb +23 -0
  26. data/lib/generators/menu_generator.rb +26 -14
  27. data/lib/generators/rspec/rspec_generator.rb +10 -3
  28. data/lib/generators/rspec/templates/spec.tt +27 -1
  29. data/lib/generators/template_renderer/template_error.rb +8 -8
  30. data/lib/generators/templates/common/git_ignore.tt +4 -1
  31. data/lib/generators/templates/common/partials/mobile_config.tt +1 -0
  32. data/lib/generators/templates/common/partials/web_config.tt +5 -2
  33. data/lib/generators/templates/common/read_me.tt +508 -0
  34. data/lib/generators/templates/helpers/partials/selenium_driver.tt +1 -3
  35. data/lib/generators/templates/helpers/raider_element.tt +159 -0
  36. data/lib/generators/templates/helpers/session_state.tt +246 -0
  37. data/lib/generators/templates/helpers/spec_helper.tt +28 -0
  38. data/lib/generators/templates/helpers/wait_helper.tt +470 -0
  39. data/lib/ruby_raider/ffmpeg.rb +7 -7
  40. data/lib/ruby_raider.rb +2 -0
  41. data/lib/scaffolding/project_detector.rb +6 -0
  42. data/lib/scaffolding/scaffolding.rb +4 -0
  43. data/lib/scaffolding/templates/component.tt +4 -0
  44. data/lib/scaffolding/templates/page_object.tt +8 -0
  45. data/lib/version +1 -1
  46. data/ruby_raider.gemspec +1 -0
  47. data/sig/generators/generator.rbs +2 -0
  48. data/sig/scaffolding/project_detector.rbs +1 -0
  49. data/sig/scaffolding/scaffolding.rbs +1 -0
  50. data/spec/commands/raider_commands_spec.rb +24 -0
  51. data/spec/fixtures/watir_master/cucumber_watir/Readme.md.snapshot +175 -0
  52. data/spec/fixtures/watir_master/cucumber_watir/features/step_definitions/login_steps.rb.snapshot +24 -0
  53. data/spec/fixtures/watir_master/cucumber_watir/page_objects/abstract/page.rb.snapshot +39 -0
  54. data/spec/fixtures/watir_master/cucumber_watir/page_objects/pages/login.rb.snapshot +33 -0
  55. data/spec/fixtures/watir_master/rspec_watir/Readme.md.snapshot +174 -0
  56. data/spec/fixtures/watir_master/rspec_watir/page_objects/abstract/page.rb.snapshot +39 -0
  57. data/spec/fixtures/watir_master/rspec_watir/page_objects/pages/login.rb.snapshot +33 -0
  58. data/spec/fixtures/watir_master/rspec_watir/spec/login_page_spec.rb.snapshot +49 -0
  59. data/spec/integration/commands/scaffolding_commands_spec.rb +49 -0
  60. data/spec/integration/commands/utility_commands_spec.rb +8 -0
  61. data/spec/integration/content/azure_content_spec.rb +4 -0
  62. data/spec/integration/content/ci_content_spec.rb +11 -0
  63. data/spec/integration/content/common_content_spec.rb +27 -0
  64. data/spec/integration/content/config_content_spec.rb +62 -0
  65. data/spec/integration/content/cucumber_reporter_content_spec.rb +25 -0
  66. data/spec/integration/content/helper_content_spec.rb +111 -0
  67. data/spec/integration/content/page_content_spec.rb +75 -4
  68. data/spec/integration/content/readme_content_spec.rb +149 -0
  69. data/spec/integration/content/syntax_validation_spec.rb +38 -0
  70. data/spec/integration/content/test_content_spec.rb +82 -0
  71. data/spec/integration/end_to_end_spec.rb +73 -28
  72. data/spec/integration/generators/automation_generator_spec.rb +50 -0
  73. data/spec/integration/generators/common_generator_spec.rb +20 -0
  74. data/spec/integration/generators/config_features_spec.rb +2 -2
  75. data/spec/integration/generators/cucumber_generator_spec.rb +36 -0
  76. data/spec/integration/generators/helpers_generator_spec.rb +60 -0
  77. data/spec/integration/generators/invoke_generators_spec.rb +70 -0
  78. data/spec/integration/generators/rspec_generator_spec.rb +20 -0
  79. data/spec/integration/helpers/session_state_spec.rb +474 -0
  80. data/spec/integration/helpers/wait_helper_mobile_spec.rb +281 -0
  81. data/spec/integration/helpers/wait_helper_spec.rb +496 -0
  82. data/spec/integration/helpers/wait_helper_support.rb +183 -0
  83. data/spec/integration/settings_helper.rb +4 -1
  84. data/spec/integration/spec_helper.rb +2 -2
  85. data/spec/menus/menu_generator_spec.rb +64 -2
  86. data/spec/scaffolding/scaffold_project_detector_spec.rb +12 -0
  87. metadata +31 -2
@@ -0,0 +1,474 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'json'
5
+ require 'timeout'
6
+ require 'selenium-webdriver'
7
+ require 'time'
8
+ require 'tmpdir'
9
+ require 'yaml'
10
+
11
+ module SessionStateSpec
12
+ TEMPLATE = File.expand_path('../../../lib/generators/templates/helpers/session_state.tt', __dir__)
13
+
14
+ # Renders the template and loads it from a temp file that is removed straight away.
15
+ def self.load_template
16
+ Dir.mktmpdir('session_state_tt') do |dir|
17
+ file = File.join(dir, 'session_state.rb')
18
+ File.write(file, ERB.new(File.read(TEMPLATE), trim_mode: '-').result)
19
+ load file
20
+ end
21
+ end
22
+ end
23
+ SessionStateSpec.load_template
24
+
25
+ module SessionStateSpec
26
+ # Implements exactly the driver calls SessionState uses and records them in order.
27
+ class FakeDriver
28
+ ORIGIN_URL = 'https://raider-test-site.onrender.com/account'
29
+
30
+ def initialize(cookies: [], local: {}, session: {}, reject: nil, url: ORIGIN_URL)
31
+ @reject = reject
32
+ @page = { cookies:, url: }
33
+ @storage = { 'localStorage' => local, 'sessionStorage' => session }
34
+ @log = { calls: [], added: [] }
35
+ end
36
+
37
+ def calls = @log[:calls]
38
+ def added = @log[:added]
39
+ def local = @storage['localStorage']
40
+ def session = @storage['sessionStorage']
41
+
42
+ def manage = self
43
+ def navigate = self
44
+ def all_cookies = @page[:cookies]
45
+ def current_url = @page[:url]
46
+ def refresh = calls << :refresh
47
+ def delete_all_cookies = calls << :delete_all_cookies
48
+
49
+ def add_cookie(cookie)
50
+ raise 'rejected' if @reject&.call(cookie)
51
+
52
+ calls << :add_cookie
53
+ added << cookie
54
+ end
55
+
56
+ def to(url)
57
+ @page[:url] = url
58
+ calls << [:to, url]
59
+ end
60
+
61
+ def execute_script(_script, *args)
62
+ kind, values = args
63
+ return @storage[kind] if values.nil?
64
+
65
+ calls << [:write, kind]
66
+ @storage[kind] = values
67
+ end
68
+ end
69
+
70
+ # The browser end of a real Selenium::WebDriver::Manager: records the wire payloads it receives.
71
+ class FakeBridge
72
+ def initialize(log, reject)
73
+ @log = log
74
+ @reject = reject
75
+ end
76
+
77
+ def add_cookie(cookie)
78
+ raise Selenium::WebDriver::Error::InvalidArgumentError, 'rejected' if @reject&.call(cookie)
79
+
80
+ @log[:calls] << :add_cookie
81
+ @log[:added] << cookie
82
+ end
83
+
84
+ def delete_all_cookies = @log[:calls] << :delete_all_cookies
85
+ def cookies = []
86
+ end
87
+
88
+ # A FakeDriver whose manage is the real Selenium Manager, so its key translation is under test.
89
+ class ManagedDriver < FakeDriver
90
+ def manage
91
+ @manage ||= Selenium::WebDriver::Manager.new(FakeBridge.new(@log, @reject))
92
+ end
93
+ end
94
+ end
95
+
96
+ describe 'SessionState' do
97
+ let(:url) { 'https://raider-test-site.onrender.com/' }
98
+ let(:driver) { SessionStateSpec::FakeDriver.new(cookies:, local: { 'token' => 'abc' }, session: { 'tab' => '1' }) }
99
+ let(:state) { SessionState.new(driver) }
100
+ let(:config) { { 'url' => url } }
101
+
102
+ def origin = 'https://raider-test-site.onrender.com'
103
+ def state_file = File.join('.raider', 'auth', 'user.json')
104
+
105
+ def cookies
106
+ [{ name: 'sid', value: 's3cr3t', domain: 'raider-test-site.onrender.com', path: '/', expires: nil,
107
+ secure: true, http_only: true, same_site: 'Lax' }]
108
+ end
109
+
110
+ around do |example|
111
+ Dir.mktmpdir do |dir|
112
+ Dir.chdir(dir) do
113
+ FileUtils.mkdir_p('config')
114
+ example.run
115
+ end
116
+ end
117
+ end
118
+
119
+ before do
120
+ File.write('config/config.yml', YAML.dump(config))
121
+ stub_const('ENV', ENV.to_h.merge('RAIDER_LOGIN_STATE' => nil))
122
+ end
123
+
124
+ def write_state(overrides = {})
125
+ FileUtils.mkdir_p(File.dirname(state_file))
126
+ data = { 'format' => 1, 'name' => 'user', 'origin' => origin, 'saved_at' => (Time.now.utc - 10).iso8601,
127
+ 'cookies' => [], 'local_storage' => {}, 'session_storage' => {} }.merge(overrides)
128
+ File.write(state_file, JSON.generate(data))
129
+ end
130
+
131
+ def saved
132
+ JSON.parse(File.read(state_file))
133
+ end
134
+
135
+ def saved_cookie(name, **attrs)
136
+ { 'name' => name, 'value' => 'v', 'domain' => 'raider-test-site.onrender.com', 'path' => '/',
137
+ 'expires' => nil, 'secure' => false, 'http_only' => false, 'same_site' => nil }.merge(attrs.transform_keys(&:to_s))
138
+ end
139
+
140
+ it 'rejects a name outside [a-z0-9_-]' do
141
+ expect { SessionState.new(driver, name: '../x') }.to raise_error(ArgumentError)
142
+ expect { SessionState.new(driver, name: 'Admin') }.to raise_error(ArgumentError)
143
+ end
144
+
145
+ it 'saves format 1 with origin, saved_at, cookies and both storages' do
146
+ state.save
147
+ expect(saved).to include('format' => 1, 'name' => 'user', 'origin' => origin,
148
+ 'local_storage' => { 'token' => 'abc' }, 'session_storage' => { 'tab' => '1' })
149
+ expect { Time.iso8601(saved['saved_at']) }.not_to raise_error
150
+ expect(saved['cookies'].first).to include('name' => 'sid', 'http_only' => true, 'secure' => true)
151
+ end
152
+
153
+ it 'writes the file 0600 in a 0700 directory, atomically' do
154
+ state.save
155
+ expect(File.stat(state_file).mode & 0o777).to eq(0o600)
156
+ expect(File.stat(File.dirname(state_file)).mode & 0o777).to eq(0o700)
157
+ expect(Dir.glob('.raider/auth/*.tmp')).to be_empty
158
+ end
159
+
160
+ context 'with a url that has a port and a path' do
161
+ let(:url) { 'https://x.test:8443/app/' }
162
+
163
+ it 'derives the origin without path or trailing slash' do
164
+ state.save
165
+ expect(saved['origin']).to eq('https://x.test:8443')
166
+ end
167
+ end
168
+
169
+ describe '#save on another origin' do
170
+ it 'goes back to the origin before reading when the login ended elsewhere' do
171
+ sso = SessionStateSpec::FakeDriver.new(url: 'https://sso.other.test/x')
172
+ SessionState.new(sso).save
173
+ expect(sso.calls).to eq([[:to, "#{origin}/"]])
174
+ end
175
+
176
+ it 'stays put when the login ended on the origin' do
177
+ state.save
178
+ expect(driver.calls).to be_empty
179
+ end
180
+ end
181
+
182
+ describe '#fresh?' do
183
+ it 'is false when the file is missing' do
184
+ expect(state.fresh?).to be(false)
185
+ end
186
+
187
+ it 'is true for a recent file of the same format and origin' do
188
+ write_state
189
+ expect(state.fresh?).to be(true)
190
+ end
191
+
192
+ it 'is false for another format, another origin or an old file' do
193
+ write_state('format' => 2)
194
+ expect(state.fresh?).to be(false)
195
+ write_state('origin' => 'https://other.test')
196
+ expect(state.fresh?).to be(false)
197
+ write_state('saved_at' => (Time.now.utc - 3601).iso8601)
198
+ expect(state.fresh?).to be(false)
199
+ end
200
+
201
+ context 'with login_state max_age 30' do
202
+ let(:config) { { 'url' => url, 'login_state' => { 'max_age' => 30 } } }
203
+
204
+ it 'is false for a file older than max_age' do
205
+ write_state('saved_at' => (Time.now.utc - 59).iso8601)
206
+ expect(state.fresh?).to be(false)
207
+ end
208
+ end
209
+ end
210
+
211
+ describe '#restore' do
212
+ let(:driver) { SessionStateSpec::ManagedDriver.new }
213
+
214
+ it 'restores live cookies for the origin and skips expired and foreign ones' do
215
+ write_state('cookies' => [
216
+ saved_cookie('session'),
217
+ saved_cookie('future', expires: (Time.now.utc + 86_400).iso8601),
218
+ saved_cookie('past', expires: (Time.now.utc - 1).iso8601),
219
+ saved_cookie('dotted', domain: '.raider-test-site.onrender.com'),
220
+ saved_cookie('foreign', domain: 'other.com')
221
+ ], 'local_storage' => { 'a' => 'b' })
222
+ warning = "[login_state] 2 cookies skipped (expired or another domain) from #{state_file}\n"
223
+ expect { expect(state.restore).to be(true) }.to output(warning).to_stderr
224
+ expect(driver.added.map { |c| c[:name] }).to eq(%w[session future dotted])
225
+ expect(driver.calls).to eq([[:to, "#{origin}/"], :delete_all_cookies, :add_cookie, :add_cookie, :add_cookie,
226
+ [:write, 'localStorage'], [:write, 'sessionStorage'], :refresh])
227
+ end
228
+
229
+ it 'hands Selenium the cookie keys it translates to the wire format' do
230
+ write_state('cookies' => [saved_cookie('session', same_site: 'Lax', http_only: true),
231
+ saved_cookie('future', expires: (Time.now.utc + 86_400).iso8601)])
232
+ expect(state.restore).to be(true)
233
+ expect(driver.added.first).to include(sameSite: 'Lax', httpOnly: true, secure: false)
234
+ expect(driver.added.flat_map(&:keys)).not_to include(:same_site, :http_only, :expires)
235
+ expect(driver.added.last[:expiry]).to be_a(Integer)
236
+ end
237
+
238
+ it 'returns false and warns for corrupt JSON' do
239
+ FileUtils.mkdir_p(File.dirname(state_file))
240
+ File.write(state_file, '{"format":')
241
+ expect { expect(state.restore).to be(false) }.to output(/\[login_state\].*user\.json/).to_stderr
242
+ end
243
+ end
244
+
245
+ it 'never prints cookie or storage values' do
246
+ leak = /s3cr3t|abc/
247
+ expect do
248
+ expect do
249
+ state.save
250
+ state.restore
251
+ write_state('cookies' => [saved_cookie('x', value: 's3cr3t')], 'local_storage' => { 'k' => 'abc' })
252
+ state.reuse(valid: -> { false }) { nil }
253
+ end.not_to output(leak).to_stdout
254
+ end.not_to output(leak).to_stderr
255
+ end
256
+
257
+ describe 'unsupported or unusable files' do
258
+ it 'warns once, naming the file, for another format' do
259
+ write_state('format' => 2)
260
+ expect { expect(state.restore).to be(false) }.to output(/\A\[login_state\] #{Regexp.escape(state_file)} has an unsupported format.*\n\z/).to_stderr
261
+ end
262
+
263
+ it 'stays silent for a missing, expired or other-origin file' do
264
+ expect do
265
+ state.fresh?
266
+ write_state('origin' => 'https://other.test')
267
+ state.fresh?
268
+ write_state('saved_at' => (Time.now.utc - 7200).iso8601)
269
+ state.fresh?
270
+ end.not_to output.to_stderr
271
+ end
272
+
273
+ it 'retries a cookie the browser rejects without sameSite' do
274
+ rejecting = SessionStateSpec::ManagedDriver.new(reject: ->(cookie) { cookie.key?(:sameSite) })
275
+ write_state('cookies' => [saved_cookie('a', same_site: 'None')])
276
+ expect(SessionState.new(rejecting).restore).to be(true)
277
+ expect(rejecting.added.map { |c| [c[:name], c.key?(:sameSite)] }).to eq([['a', false]])
278
+ end
279
+ end
280
+
281
+ describe '#reuse' do
282
+ def run_reuse(**options)
283
+ count = 0
284
+ silence { state.reuse(**options) { count += 1 } }
285
+ count
286
+ end
287
+
288
+ def silence
289
+ original = $stderr
290
+ $stderr = StringIO.new
291
+ yield
292
+ ensure
293
+ $stderr = original
294
+ end
295
+
296
+ def warnings
297
+ original = $stderr
298
+ $stderr = StringIO.new
299
+ yield
300
+ $stderr.string
301
+ ensure
302
+ $stderr = original
303
+ end
304
+
305
+ it 'logs in and saves when there is no file' do
306
+ expect(run_reuse).to eq(1)
307
+ expect(File).to exist(state_file)
308
+ expect(driver.calls).not_to include(:delete_all_cookies)
309
+ end
310
+
311
+ it 'clears the restored cookies and storage before the fallback login' do
312
+ managed = SessionStateSpec::ManagedDriver.new
313
+ write_state('cookies' => [saved_cookie('a')], 'local_storage' => { 'k' => 'v' })
314
+ silence { SessionState.new(managed).reuse(valid: -> { false }) { managed.calls << :login } }
315
+ after_refresh = managed.calls.drop(managed.calls.index(:refresh) + 1)
316
+ expect(after_refresh).to eq([:delete_all_cookies, [:write, 'localStorage'], [:write, 'sessionStorage'], :login])
317
+ expect(managed.local).to eq({})
318
+ end
319
+
320
+ it 'clears a half-applied restore before the fallback login' do
321
+ broken = SessionStateSpec::ManagedDriver.new(reject: ->(_cookie) { true })
322
+ write_state('cookies' => [saved_cookie('a')])
323
+ silence { SessionState.new(broken).reuse { broken.calls << :login } }
324
+ expect(broken.calls.last(4)).to eq([:delete_all_cookies, [:write, 'localStorage'], [:write, 'sessionStorage'],
325
+ :login])
326
+ end
327
+
328
+ it 'logs in without saving, with one warning, when the auth directory cannot be created' do
329
+ FileUtils.mkdir_p('.raider')
330
+ File.chmod(0o500, '.raider')
331
+ count = 0
332
+ output = warnings { expect(state.reuse { count += 1 }).to be(true) }
333
+ expect(count).to eq(1)
334
+ expect(output.lines).to match([start_with('[login_state] ')])
335
+ ensure
336
+ File.chmod(0o700, '.raider')
337
+ end
338
+
339
+ it 'logs in without saving, with one warning, when the lock cannot be opened' do
340
+ FileUtils.mkdir_p(File.join('.raider', 'auth', 'user.lock'))
341
+ count = 0
342
+ output = warnings { expect(state.reuse { count += 1 }).to be(true) }
343
+ expect(count).to eq(1)
344
+ expect(output.lines).to match([start_with('[login_state] ')])
345
+ expect(File).not_to exist(state_file)
346
+ end
347
+
348
+ it 'keeps the login, with one warning naming the file, when saving after it fails' do
349
+ allow(driver).to receive(:execute_script).and_raise(Selenium::WebDriver::Error::JavascriptError, 'abc')
350
+ count = 0
351
+ output = warnings { expect(state.reuse { count += 1 }).to be(true) }
352
+ expect(count).to eq(1)
353
+ expect(output.lines).to match([include(state_file, 'JavascriptError')])
354
+ expect(output).not_to include('abc')
355
+ end
356
+
357
+ it 'restores and skips the login when fresh and valid' do
358
+ write_state
359
+ expect(run_reuse(valid: -> { true })).to eq(0)
360
+ expect(driver.calls).to include(:refresh)
361
+ end
362
+
363
+ it 'falls back once when valid returns false' do
364
+ write_state('saved_at' => (Time.now.utc - 100).iso8601)
365
+ before = Time.iso8601(saved['saved_at'])
366
+ expect(run_reuse(valid: -> { false })).to eq(1)
367
+ expect(Time.iso8601(saved['saved_at'])).to be > before
368
+ end
369
+
370
+ it 'falls back when the file is corrupt' do
371
+ FileUtils.mkdir_p(File.dirname(state_file))
372
+ File.write(state_file, '{"format":')
373
+ expect(run_reuse).to eq(1)
374
+ expect(saved['format']).to eq(1)
375
+ end
376
+
377
+ it 'falls back once, with one warning, when valid raises' do
378
+ write_state
379
+ count = 0
380
+ expect { state.reuse(valid: -> { raise 'boom' }) { count += 1 } }.to output(/\A\[login_state\] validity check failed \(RuntimeError\), logging in again\n\z/).to_stderr
381
+ expect(count).to eq(1)
382
+ end
383
+
384
+ it 'falls back once, with one warning, when restore raises' do
385
+ write_state('cookies' => [saved_cookie('a')])
386
+ broken = SessionStateSpec::FakeDriver.new(reject: ->(_cookie) { true })
387
+ count = 0
388
+ expect { SessionState.new(broken).reuse { count += 1 } }.to output(/\A\[login_state\] could not restore.*\n\z/).to_stderr
389
+ expect(count).to eq(1)
390
+ end
391
+
392
+ it 'warns once for another format and logs in' do
393
+ write_state('format' => 2)
394
+ count = 0
395
+ expect { state.reuse { count += 1 } }.to output(/\A\[login_state\] .*unsupported format.*\n\z/).to_stderr
396
+ expect(count).to eq(1)
397
+ end
398
+
399
+ it 'RAIDER_LOGIN_STATE=off logs in and does not touch the disk' do
400
+ stub_const('ENV', ENV.to_h.merge('RAIDER_LOGIN_STATE' => 'off'))
401
+ expect(run_reuse).to eq(1)
402
+ expect(File).not_to exist('.raider')
403
+ end
404
+
405
+ it 'RAIDER_LOGIN_STATE=refresh logs in even when fresh' do
406
+ write_state('saved_at' => (Time.now.utc - 100).iso8601)
407
+ before = Time.iso8601(saved['saved_at'])
408
+ stub_const('ENV', ENV.to_h.merge('RAIDER_LOGIN_STATE' => 'refresh'))
409
+ expect(run_reuse).to eq(1)
410
+ expect(Time.iso8601(saved['saved_at'])).to be > before
411
+ end
412
+
413
+ it 'lets concurrent callers restore a fresh login at the same time' do
414
+ write_state('cookies' => [saved_cookie('session')])
415
+ arrived = Queue.new
416
+ checked = Queue.new
417
+ threads = Array.new(2) do
418
+ Thread.new do
419
+ valid = lambda do
420
+ arrived << 1
421
+ Timeout.timeout(2) { sleep 0.01 until arrived.size == 2 }
422
+ checked << 1
423
+ end
424
+ SessionState.new(SessionStateSpec::FakeDriver.new(cookies:)).reuse(valid:) { raise 'logged in' }
425
+ end
426
+ end
427
+ threads.each(&:join)
428
+ expect(checked.size).to eq(2)
429
+ end
430
+
431
+ it 'restores a login another worker saved meanwhile, without a fallback warning' do
432
+ write_state('cookies' => [saved_cookie('session')])
433
+ checks = 0
434
+ valid = lambda do
435
+ checks += 1
436
+ write_state('cookies' => [saved_cookie('session')], 'saved_at' => Time.now.utc.iso8601) if checks == 1
437
+ checks > 1
438
+ end
439
+ logins = 0
440
+ expect { state.reuse(valid:) { logins += 1 } }.not_to output.to_stderr
441
+ expect([logins, checks]).to eq([0, 2])
442
+ end
443
+
444
+ it 'warns once when the second restore after a peer save is also invalid' do
445
+ write_state('cookies' => [saved_cookie('session')])
446
+ checks = 0
447
+ valid = lambda do
448
+ checks += 1
449
+ write_state('cookies' => [saved_cookie('session')], 'saved_at' => Time.now.utc.iso8601) if checks == 1
450
+ false
451
+ end
452
+ logins = 0
453
+ expect { state.reuse(valid:) { logins += 1 } }
454
+ .to output(/\A\[login_state\] login restored from \S+ is no longer valid, logging in again\n\z/).to_stderr
455
+ expect(logins).to eq(1)
456
+ end
457
+
458
+ it 'serializes concurrent callers on the lock' do
459
+ count = 0
460
+ drivers = Array.new(2) { SessionStateSpec::FakeDriver.new(cookies:) }
461
+ threads = drivers.map do |fake|
462
+ Thread.new do
463
+ SessionState.new(fake).reuse do
464
+ sleep 0.2
465
+ count += 1
466
+ end
467
+ end
468
+ end
469
+ threads.each(&:join)
470
+ expect(count).to eq(1)
471
+ expect(drivers.count { |fake| fake.calls.include?(:refresh) }).to eq(1)
472
+ end
473
+ end
474
+ end