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
|
@@ -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
|
|
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)
|
|
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")
|
|
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(
|
|
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
|
-
|
|
330
|
-
|
|
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
|
|
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
|
-
|
|
463
|
-
|
|
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
|