ruby_raider 3.13.0 → 4.0.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 (225) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/runtime.yml +38 -0
  3. data/.github/workflows/system_tests.yml +1 -23
  4. data/.reek.yml +1 -0
  5. data/.rspec +1 -0
  6. data/Gemfile +2 -0
  7. data/README.md +16 -49
  8. data/RELEASE.md +22 -0
  9. data/bin/raider +5 -0
  10. data/lib/commands/scaffolding_commands.rb +2 -25
  11. data/lib/commands/utility_commands.rb +23 -3
  12. data/lib/generators/automation/automation_generator.rb +0 -5
  13. data/lib/generators/automation/templates/component.tt +1 -0
  14. data/lib/generators/automation/templates/home.tt +2 -2
  15. data/lib/generators/automation/templates/login.tt +6 -26
  16. data/lib/generators/automation/templates/page.tt +23 -18
  17. data/lib/generators/automation/templates/partials/home_page_selector.tt +1 -2
  18. data/lib/generators/automation/templates/partials/initialize_selector.tt +0 -8
  19. data/lib/generators/automation/templates/partials/pdp_page_selector.tt +1 -2
  20. data/lib/generators/automation/templates/partials/url_methods.tt +8 -2
  21. data/lib/generators/automation/templates/partials/visit_method.tt +0 -5
  22. data/lib/generators/automation/templates/pdp.tt +1 -9
  23. data/lib/generators/common_generator.rb +0 -2
  24. data/lib/generators/generator.rb +12 -19
  25. data/lib/generators/helper_generator.rb +21 -19
  26. data/lib/generators/infrastructure/templates/azure_pipelines.tt +1 -1
  27. data/lib/generators/infrastructure/templates/github.tt +2 -3
  28. data/lib/generators/infrastructure/templates/github_appium.tt +0 -8
  29. data/lib/generators/invoke_generators.rb +4 -23
  30. data/lib/generators/menu_generator.rb +1 -9
  31. data/lib/generators/rspec/templates/accessibility_spec.tt +1 -9
  32. data/lib/generators/rspec/templates/spec.tt +8 -24
  33. data/lib/generators/template_renderer/partial_cache.rb +3 -1
  34. data/lib/generators/template_renderer.rb +1 -1
  35. data/lib/generators/templates/common/gemfile.tt +3 -10
  36. data/lib/generators/templates/common/git_ignore.tt +0 -5
  37. data/lib/generators/templates/common/partials/mobile_config.tt +3 -1
  38. data/lib/generators/templates/common/partials/web_config.tt +10 -1
  39. data/lib/generators/templates/common/rakefile.tt +0 -15
  40. data/lib/generators/templates/common/read_me.tt +357 -250
  41. data/lib/generators/templates/common/rubocop.tt +0 -2
  42. data/lib/generators/templates/helpers/driver_helper.tt +8 -0
  43. data/lib/generators/templates/helpers/gesture_helper.tt +78 -0
  44. data/lib/generators/templates/helpers/network_helper.tt +521 -0
  45. data/lib/generators/templates/helpers/partials/quit_driver.tt +0 -2
  46. data/lib/generators/templates/helpers/partials/screenshot.tt +0 -2
  47. data/lib/generators/templates/helpers/partials/selenium_driver.tt +5 -2
  48. data/lib/generators/templates/helpers/screenshot_helper.tt +490 -0
  49. data/lib/generators/templates/helpers/spec_helper.tt +39 -29
  50. data/lib/generators/templates/helpers/trace_helper.tt +240 -95
  51. data/lib/generators/templates/helpers/video_helper.tt +3 -8
  52. data/lib/ruby_raider/runtime/actionability.rb +129 -0
  53. data/lib/ruby_raider/runtime/actionable.rb +49 -0
  54. data/lib/ruby_raider/runtime/by_role.rb +55 -0
  55. data/lib/ruby_raider/runtime/config.rb +79 -0
  56. data/lib/ruby_raider/runtime/context.rb +38 -0
  57. data/lib/ruby_raider/runtime/errors.rb +69 -0
  58. data/lib/ruby_raider/runtime/finders.rb +73 -0
  59. data/lib/ruby_raider/runtime/js/actionability.js +134 -0
  60. data/lib/ruby_raider/runtime/js/role_engine.js +315 -0
  61. data/lib/ruby_raider/runtime/locator_converter.rb +72 -0
  62. data/lib/ruby_raider/runtime/matchers/base.rb +222 -0
  63. data/lib/ruby_raider/runtime/matchers/element.rb +183 -0
  64. data/lib/ruby_raider/runtime/matchers/page.rb +71 -0
  65. data/lib/ruby_raider/runtime/matchers/presence.rb +41 -0
  66. data/lib/ruby_raider/runtime/matchers/retrying_block.rb +104 -0
  67. data/lib/ruby_raider/runtime/observer.rb +170 -0
  68. data/lib/ruby_raider/runtime/role_finder.rb +83 -0
  69. data/lib/ruby_raider/runtime/rspec.rb +78 -0
  70. data/lib/ruby_raider/runtime/selectors.rb +60 -0
  71. data/lib/ruby_raider/runtime/strict.rb +142 -0
  72. data/lib/ruby_raider/runtime/wait.rb +84 -0
  73. data/lib/ruby_raider/runtime.rb +20 -0
  74. data/lib/scaffolding/name_normalizer.rb +1 -1
  75. data/lib/scaffolding/project_detector.rb +2 -28
  76. data/lib/scaffolding/scaffolding.rb +0 -20
  77. data/lib/scaffolding/templates/component.tt +0 -14
  78. data/lib/scaffolding/templates/page_object.tt +2 -14
  79. data/lib/scaffolding/templates/spec.tt +1 -9
  80. data/lib/utilities/utilities.rb +10 -8
  81. data/lib/version +1 -1
  82. data/ruby_raider.gemspec +2 -1
  83. data/sig/commands/scaffolding_commands.rbs +0 -2
  84. data/sig/commands/utility_commands.rbs +6 -0
  85. data/sig/generators/generator.rbs +0 -4
  86. data/sig/generators/helper_generator.rbs +0 -1
  87. data/sig/generators/menu_generator.rbs +0 -1
  88. data/sig/ruby_raider/runtime/actionability.rbs +25 -0
  89. data/sig/ruby_raider/runtime/actionable.rbs +13 -0
  90. data/sig/ruby_raider/runtime/by_role.rbs +15 -0
  91. data/sig/ruby_raider/runtime/config.rbs +26 -0
  92. data/sig/ruby_raider/runtime/context.rbs +12 -0
  93. data/sig/ruby_raider/runtime/errors.rbs +36 -0
  94. data/sig/ruby_raider/runtime/finders.rbs +26 -0
  95. data/sig/ruby_raider/runtime/locator_converter.rbs +21 -0
  96. data/sig/ruby_raider/runtime/matchers/base.rbs +64 -0
  97. data/sig/ruby_raider/runtime/matchers/element.rbs +78 -0
  98. data/sig/ruby_raider/runtime/matchers/page.rbs +38 -0
  99. data/sig/ruby_raider/runtime/matchers/presence.rbs +21 -0
  100. data/sig/ruby_raider/runtime/matchers/retrying_block.rbs +27 -0
  101. data/sig/ruby_raider/runtime/observer.rbs +46 -0
  102. data/sig/ruby_raider/runtime/role_finder.rbs +18 -0
  103. data/sig/ruby_raider/runtime/selectors.rbs +21 -0
  104. data/sig/ruby_raider/runtime/strict.rbs +35 -0
  105. data/sig/ruby_raider/runtime/wait.rbs +17 -0
  106. data/sig/ruby_raider/runtime.rbs +2 -0
  107. data/sig/scaffolding/project_detector.rbs +0 -4
  108. data/sig/scaffolding/scaffolding.rbs +0 -5
  109. data/sig/utilities/utilities.rbs +2 -1
  110. data/sig/vendor/selenium_webdriver.rbs +71 -0
  111. data/spec/commands/raider_commands_spec.rb +38 -3
  112. data/spec/commands/scaffolding_commands_spec.rb +27 -0
  113. data/spec/commands/utility_commands_spec.rb +53 -0
  114. data/spec/generators/gesture_helper_spec.rb +123 -0
  115. data/spec/generators/network_helper_spec.rb +634 -0
  116. data/spec/generators/screenshot_helper_spec.rb +776 -0
  117. data/spec/generators/template_renderer_spec.rb +0 -4
  118. data/spec/generators/trace_helper_spec.rb +489 -97
  119. data/spec/generators/video_helper_spec.rb +0 -9
  120. data/spec/integration/commands/browser_update_after_creation_spec.rb +0 -8
  121. data/spec/integration/commands/scaffolding_commands_spec.rb +12 -70
  122. data/spec/integration/commands/utility_commands_spec.rb +19 -41
  123. data/spec/integration/content/azure_content_spec.rb +2 -6
  124. data/spec/integration/content/ci_content_spec.rb +1 -55
  125. data/spec/integration/content/common_content_spec.rb +1 -118
  126. data/spec/integration/content/config_content_spec.rb +34 -31
  127. data/spec/integration/content/gemfile_content_spec.rb +7 -72
  128. data/spec/integration/content/helper_content_spec.rb +105 -99
  129. data/spec/integration/content/page_content_spec.rb +85 -87
  130. data/spec/integration/content/readme_content_spec.rb +134 -75
  131. data/spec/integration/content/rspec_reporter_content_spec.rb +2 -2
  132. data/spec/integration/content/rubocop_content_spec.rb +1 -1
  133. data/spec/integration/content/syntax_validation_spec.rb +2 -2
  134. data/spec/integration/content/test_content_spec.rb +23 -147
  135. data/spec/integration/end_to_end_features_spec.rb +1 -43
  136. data/spec/integration/end_to_end_spec.rb +319 -326
  137. data/spec/integration/generators/automation_generator_spec.rb +3 -37
  138. data/spec/integration/generators/axe_addon_spec.rb +2 -35
  139. data/spec/integration/generators/azure_generator_spec.rb +1 -1
  140. data/spec/integration/generators/common_generator_spec.rb +1 -40
  141. data/spec/integration/generators/config_features_spec.rb +1 -24
  142. data/spec/integration/generators/github_generator_spec.rb +0 -12
  143. data/spec/integration/generators/helpers_generator_spec.rb +74 -80
  144. data/spec/integration/generators/invoke_generators_spec.rb +54 -45
  145. data/spec/integration/generators/rspec_generator_spec.rb +0 -6
  146. data/spec/integration/scaffolding_e2e_spec.rb +17 -179
  147. data/spec/integration/spec_helper.rb +4 -6
  148. data/spec/integration/trace_format_checker.rb +11 -2
  149. data/spec/integration/trace_page_server.rb +72 -5
  150. data/spec/menus/menu_generator_spec.rb +14 -39
  151. data/spec/runtime/actionability_spec.rb +207 -0
  152. data/spec/runtime/actionable_spec.rb +125 -0
  153. data/spec/runtime/browser/actionability_script_browser_spec.rb +150 -0
  154. data/spec/runtime/browser/actionable_browser_spec.rb +98 -0
  155. data/spec/runtime/browser/finders_browser_spec.rb +148 -0
  156. data/spec/runtime/browser/matchers_browser_spec.rb +67 -0
  157. data/spec/runtime/browser/retrying_block_browser_spec.rb +37 -0
  158. data/spec/runtime/browser/role_engine_browser_spec.rb +184 -0
  159. data/spec/runtime/browser/strict_browser_spec.rb +38 -0
  160. data/spec/runtime/by_role_spec.rb +52 -0
  161. data/spec/runtime/config_spec.rb +79 -0
  162. data/spec/runtime/context_spec.rb +73 -0
  163. data/spec/runtime/errors_spec.rb +89 -0
  164. data/spec/runtime/finders_spec.rb +165 -0
  165. data/spec/runtime/fixtures/actionability.html +70 -0
  166. data/spec/runtime/fixtures/locators.html +46 -0
  167. data/spec/runtime/fixtures/matchers.html +33 -0
  168. data/spec/runtime/fixtures/roles.html +162 -0
  169. data/spec/runtime/matchers/base_spec.rb +165 -0
  170. data/spec/runtime/matchers/element_spec.rb +277 -0
  171. data/spec/runtime/matchers/page_spec.rb +102 -0
  172. data/spec/runtime/matchers/presence_spec.rb +59 -0
  173. data/spec/runtime/matchers/retrying_block_spec.rb +165 -0
  174. data/spec/runtime/observer_spec.rb +341 -0
  175. data/spec/runtime/role_finder_spec.rb +156 -0
  176. data/spec/runtime/role_query_spec.rb +75 -0
  177. data/spec/runtime/selectors_spec.rb +135 -0
  178. data/spec/runtime/strict_spec.rb +257 -0
  179. data/spec/runtime/support/browser.rb +48 -0
  180. data/spec/runtime/support/fake_bridge.rb +117 -0
  181. data/spec/runtime/wait_spec.rb +71 -0
  182. data/spec/scaffolding/name_normalizer_spec.rb +3 -2
  183. data/spec/scaffolding/scaffold_project_detector_spec.rb +4 -43
  184. data/spec/system/selenium_spec.rb +1 -5
  185. data/spec/system/support/system_test_helper.rb +2 -5
  186. data/spec/utilities/headless_config_spec.rb +0 -7
  187. data/spec/utilities/utilities_spec.rb +24 -0
  188. metadata +113 -48
  189. data/lib/generators/automation/templates/header.tt +0 -9
  190. data/lib/generators/cucumber/cucumber_generator.rb +0 -58
  191. data/lib/generators/cucumber/templates/accessibility_feature.tt +0 -5
  192. data/lib/generators/cucumber/templates/accessibility_steps.tt +0 -17
  193. data/lib/generators/cucumber/templates/cucumber.tt +0 -1
  194. data/lib/generators/cucumber/templates/env.tt +0 -7
  195. data/lib/generators/cucumber/templates/feature.tt +0 -21
  196. data/lib/generators/cucumber/templates/partials/appium_env.tt +0 -26
  197. data/lib/generators/cucumber/templates/partials/driver_world.tt +0 -18
  198. data/lib/generators/cucumber/templates/partials/mobile_steps.tt +0 -20
  199. data/lib/generators/cucumber/templates/partials/selenium_env.tt +0 -120
  200. data/lib/generators/cucumber/templates/partials/watir_env.tt +0 -48
  201. data/lib/generators/cucumber/templates/partials/watir_world.tt +0 -4
  202. data/lib/generators/cucumber/templates/partials/web_steps.tt +0 -28
  203. data/lib/generators/cucumber/templates/steps.tt +0 -5
  204. data/lib/generators/cucumber/templates/user_factory.tt +0 -15
  205. data/lib/generators/cucumber/templates/world.tt +0 -7
  206. data/lib/generators/templates/helpers/browser_helper.tt +0 -31
  207. data/lib/generators/templates/helpers/raider_element.tt +0 -159
  208. data/lib/generators/templates/helpers/wait_helper.tt +0 -611
  209. data/lib/scaffolding/templates/feature.tt +0 -6
  210. data/lib/scaffolding/templates/steps.tt +0 -19
  211. data/sig/generators/cucumber/cucumber_generator.rbs +0 -12
  212. data/spec/fixtures/watir_master/cucumber_watir/Readme.md.snapshot +0 -175
  213. data/spec/fixtures/watir_master/cucumber_watir/features/step_definitions/login_steps.rb.snapshot +0 -24
  214. data/spec/fixtures/watir_master/cucumber_watir/page_objects/abstract/page.rb.snapshot +0 -37
  215. data/spec/fixtures/watir_master/cucumber_watir/page_objects/pages/login.rb.snapshot +0 -33
  216. data/spec/fixtures/watir_master/rspec_watir/Readme.md.snapshot +0 -174
  217. data/spec/fixtures/watir_master/rspec_watir/page_objects/abstract/page.rb.snapshot +0 -37
  218. data/spec/fixtures/watir_master/rspec_watir/page_objects/pages/login.rb.snapshot +0 -33
  219. data/spec/fixtures/watir_master/rspec_watir/spec/login_page_spec.rb.snapshot +0 -49
  220. data/spec/integration/content/cucumber_reporter_content_spec.rb +0 -161
  221. data/spec/integration/generators/cucumber_generator_spec.rb +0 -100
  222. data/spec/integration/helpers/wait_helper_mobile_spec.rb +0 -281
  223. data/spec/integration/helpers/wait_helper_spec.rb +0 -679
  224. data/spec/integration/helpers/wait_helper_support.rb +0 -183
  225. data/spec/system/watir_spec.rb +0 -38
@@ -0,0 +1,634 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+ require 'fileutils'
5
+ require 'tmpdir'
6
+ require 'uri'
7
+ require_relative '../../lib/generators/helper_generator'
8
+
9
+ # Renders network_helper.tt for a selenium/rspec project and loads it.
10
+ module NetworkHelperRenderer
11
+ TEMPLATE = File.expand_path('../../lib/generators/templates/helpers/network_helper.tt', __dir__)
12
+
13
+ def render_and_load_network_helper
14
+ generator = HelpersGenerator.new(%w[selenium rspec proj])
15
+ rendered = ERB.new(File.read(TEMPLATE), trim_mode: '-').result(generator.instance_eval { binding })
16
+ Dir.mktmpdir('network-helper') do |dir|
17
+ path = File.join(dir, 'network_helper.rb')
18
+ File.write(path, rendered)
19
+ Object.send(:remove_const, :NetworkHelper) if Object.const_defined?(:NetworkHelper)
20
+ load path
21
+ end
22
+ end
23
+ end
24
+
25
+ RSpec.describe 'NetworkHelper::Matcher' do
26
+ include NetworkHelperRenderer
27
+
28
+ before { render_and_load_network_helper }
29
+
30
+ let(:matcher) { NetworkHelper::Matcher }
31
+
32
+ def matches?(url, request_url, http_method = 'GET', **options)
33
+ matcher.new(url, **options).match?(request_url, http_method)
34
+ end
35
+
36
+ describe '#match?' do
37
+ it 'matches ** across hosts but not the query' do
38
+ expect(matches?('**/api/users', 'https://a.test/api/users')).to be(true)
39
+ expect(matches?('**/api/users', 'https://a.test/api/users?page=2')).to be(false)
40
+ expect(matches?('**/api/users?*', 'https://a.test/api/users?page=2')).to be(true)
41
+ end
42
+
43
+ it 'stops * at a slash' do
44
+ expect(matches?('https://shop.test/api/items/*', 'https://shop.test/api/items/7')).to be(true)
45
+ expect(matches?('https://shop.test/api/items/*', 'https://shop.test/api/items/7/x')).to be(false)
46
+ end
47
+
48
+ it 'treats other characters literally' do
49
+ expect(matches?('https://a.test/a.b', 'https://a.test/aXb')).to be(false)
50
+ expect(matches?('https://a.test/a.b', 'https://a.test/a.b')).to be(true)
51
+ end
52
+
53
+ it 'matches a Regexp against the full URL' do
54
+ expect(matches?(%r{/api/items/\d+$}, 'https://a.test/api/items/42')).to be(true)
55
+ end
56
+
57
+ it 'compares the method case-insensitively, nil matches all' do
58
+ expect(matches?('**/api', 'https://a.test/api', 'GET', method: :post)).to be(false)
59
+ expect(matches?('**/api', 'https://a.test/api', 'POST', method: :post)).to be(true)
60
+ expect(matches?('**/api', 'https://a.test/api', 'DELETE')).to be(true)
61
+ end
62
+
63
+ it 'rejects a url that is neither String nor Regexp' do
64
+ expect { matcher.new(42) }.to raise_error(ArgumentError)
65
+ end
66
+ end
67
+
68
+ describe '#intercept_patterns' do
69
+ it 'narrows to the literal parts of the glob' do
70
+ expect(matcher.new('**/api/users').intercept_patterns).to be_nil
71
+ expect(matcher.new('https://*.test/api').intercept_patterns).to be_nil
72
+ expect(matcher.new('https://a.test/api/users?*').intercept_patterns)
73
+ .to eq([{ type: 'pattern', protocol: 'https', hostname: 'a.test', pathname: '/api/users' }])
74
+ expect(matcher.new('https://shop.test/api/*').intercept_patterns)
75
+ .to eq([{ type: 'pattern', protocol: 'https', hostname: 'shop.test' }])
76
+ expect(matcher.new('http://127.0.0.1:4567/api').intercept_patterns)
77
+ .to eq([{ type: 'pattern', protocol: 'http', hostname: '127.0.0.1', port: '4567', pathname: '/api' }])
78
+ end
79
+
80
+ it 'does not narrow on an IPv6 host, userinfo, or a path BiDi patterns reject' do
81
+ expect(matcher.new('http://[::1]:8080/api').intercept_patterns).to be_nil
82
+ expect(matcher.new('https://u@a.test/x').intercept_patterns).to be_nil
83
+ expect(matcher.new('https://a.test/a(b)').intercept_patterns)
84
+ .to eq([{ type: 'pattern', protocol: 'https', hostname: 'a.test' }])
85
+ expect(matcher.new('https://a.test/a{b}').intercept_patterns)
86
+ .to eq([{ type: 'pattern', protocol: 'https', hostname: 'a.test' }])
87
+ end
88
+
89
+ it 'is nil when nothing literal is left' do
90
+ expect(matcher.new(/x/).intercept_patterns).to be_nil
91
+ expect(matcher.new('**').intercept_patterns).to be_nil
92
+ end
93
+ end
94
+
95
+ describe 'narrowing never misses a matching URL' do
96
+ let(:urls) do
97
+ %w[https://a.test/api/users https://a.test/api/users?page=2 https://shop.test/api/items/7
98
+ http://127.0.0.1:4567/api http://127.0.0.1:4567/api/x https://b.test/x/api/users https://a.test]
99
+ end
100
+ let(:globs) do
101
+ ['**/api/users', '**/api/users?*', 'https://a.test/api/users?*', 'https://shop.test/api/*',
102
+ 'http://127.0.0.1:4567/api', 'http://127.0.0.1:4567/**', 'https://*.test/api/users', '**', 'https://a.test**']
103
+ end
104
+
105
+ def covered?(pattern, uri)
106
+ { protocol: uri.scheme, hostname: uri.host, port: uri.port.to_s, pathname: uri.path }.all? do |key, value|
107
+ !pattern.key?(key) || pattern[key] == value
108
+ end
109
+ end
110
+
111
+ it 'keeps every URL the glob matches inside a narrowed pattern' do
112
+ globs.each do |glob|
113
+ matcher_for_glob = matcher.new(glob)
114
+ patterns = matcher_for_glob.intercept_patterns
115
+ urls.select { |url| matcher_for_glob.match?(url, 'GET') }.each do |url|
116
+ next if patterns.nil?
117
+
118
+ uri = URI(url)
119
+ expect(patterns.any? { |pattern| covered?(pattern, uri) }).to be(true), "#{glob} narrows past #{url}"
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+
126
+ # Stands in for Selenium::WebDriver::BiDi (4.52), like FakeTraceBidi: emit runs each callback on its own thread.
127
+ class FakeNetworkBidi
128
+ attr_reader :subscribed, :commands
129
+ attr_accessor :failure, :command_failure
130
+
131
+ def initialize
132
+ @subscribed = []
133
+ @callbacks = Hash.new { |hash, event| hash[event] = [] }
134
+ @commands = []
135
+ @lock = Mutex.new
136
+ end
137
+
138
+ def session
139
+ self
140
+ end
141
+
142
+ def subscribe(events, _browsing_contexts = nil)
143
+ raise failure if failure
144
+
145
+ @subscribed << Array(events)
146
+ {}
147
+ end
148
+
149
+ def add_callback(event, &block)
150
+ @callbacks[event] << block
151
+ block.object_id
152
+ end
153
+
154
+ def send_cmd(method, **params)
155
+ raise command_failure if command_failure && method != 'network.addIntercept'
156
+
157
+ @lock.synchronize do
158
+ @commands << [method, params]
159
+ method == 'network.addIntercept' ? { 'intercept' => "i-#{named('network.addIntercept').size}" } : {}
160
+ end
161
+ end
162
+
163
+ def emit(event, params)
164
+ @callbacks[event].map { |callback| Thread.new { callback.call(params) } }.each(&:join)
165
+ end
166
+
167
+ def named(method)
168
+ @commands.select { |name, _| name == method }.map(&:last)
169
+ end
170
+ end
171
+
172
+ # rubocop:disable RSpec/MultipleMemoizedHelpers, RSpec/VerifiedDoubles, RSpec/MultipleExpectations
173
+ RSpec.describe 'NetworkHelper::Router' do
174
+ include NetworkHelperRenderer
175
+
176
+ let(:bidi) { FakeNetworkBidi.new }
177
+ let(:browser) { 'chrome' }
178
+ let(:fake_driver) do
179
+ double('driver', bidi: bidi, capabilities: double('caps', browser_name: browser))
180
+ end
181
+ let(:host) do
182
+ fake = fake_driver
183
+ Class.new do
184
+ include NetworkHelper
185
+
186
+ define_method(:driver) { fake }
187
+ end.new
188
+ end
189
+ let(:accept) { [{ 'name' => 'Accept', 'value' => { 'type' => 'string', 'value' => '*/*' } }] }
190
+
191
+ before do
192
+ render_and_load_network_helper
193
+ ENV.delete('BIDI')
194
+ end
195
+
196
+ after do
197
+ NetworkHelper.observer = nil
198
+ ENV.delete('BIDI')
199
+ end
200
+
201
+ def event(id: 'r1', url: 'https://a.test/api/users', http_method: 'GET', intercepts: ['i-1'], blocked: true,
202
+ headers: accept)
203
+ { 'isBlocked' => blocked, 'intercepts' => intercepts, 'redirectCount' => 0,
204
+ 'request' => { 'request' => id, 'url' => url, 'method' => http_method, 'headers' => headers } }
205
+ end
206
+
207
+ def emit(**options)
208
+ bidi.emit('network.beforeRequestSent', event(**options))
209
+ end
210
+
211
+ def header_pairs(params)
212
+ params[:headers].to_h { |header| [header[:name], header[:value][:value]] }
213
+ end
214
+
215
+ it 'subscribes and adds one callback per driver' do
216
+ host.mock_request('**/a')
217
+ host.mock_request('**/b')
218
+ expect(bidi.subscribed).to eq([['network.beforeRequestSent']])
219
+ expect(bidi.named('network.addIntercept').size).to eq(2)
220
+ end
221
+
222
+ it 'adds the intercept without urlPatterns when nothing is literal, with them otherwise' do
223
+ host.mock_request('**/a')
224
+ host.mock_request('https://a.test/x')
225
+ first, second = bidi.named('network.addIntercept')
226
+ expect(first).to eq(phases: ['beforeRequestSent'])
227
+ expect(second).to include(urlPatterns: [{ type: 'pattern', protocol: 'https', hostname: 'a.test', pathname: '/x' }])
228
+ end
229
+
230
+ it 'fulfils with json' do
231
+ host.mock_request('**/api/users', json: { users: [] })
232
+ emit
233
+ params = bidi.named('network.provideResponse').first
234
+ expect(params).to include(request: 'r1', statusCode: 200, reasonPhrase: 'OK')
235
+ expect(header_pairs(params)).to eq('Content-Type' => 'application/json', 'Access-Control-Allow-Origin' => '*')
236
+ expect(params[:body]).to eq(type: 'base64', value: ['{"users":[]}'].pack('m0'))
237
+ end
238
+
239
+ it 'keeps a header or content_type given for json' do
240
+ host.mock_request('**/a', json: {}, headers: { 'content-type' => 'application/vnd.api+json' })
241
+ host.mock_request('**/b', json: {}, content_type: 'text/x-json', status: 201)
242
+ emit(url: 'https://a.test/a')
243
+ emit(id: 'r2', url: 'https://a.test/b', intercepts: ['i-2'])
244
+ first, second = bidi.named('network.provideResponse')
245
+ expect(header_pairs(first)).to include('content-type' => 'application/vnd.api+json')
246
+ expect(header_pairs(second)).to include('Content-Type' => 'text/x-json')
247
+ expect(second).to include(statusCode: 201, reasonPhrase: 'Created')
248
+ end
249
+
250
+ it 'sends body bytes exactly' do
251
+ host.mock_request('**/api/users', body: "\xFF\x00é".b, status: 201)
252
+ emit
253
+ params = bidi.named('network.provideResponse').first
254
+ expect(params[:body][:value].unpack1('m0')).to eq("\xFF\x00é".b)
255
+ expect(params[:reasonPhrase]).to eq('Created')
256
+ end
257
+
258
+ it 'rejects a block given together with a response' do
259
+ [{ status: 500 }, { json: {} }, { body: 'x' }, { headers: { 'a' => 'b' } }, { content_type: 'text/plain' }].each do |options|
260
+ expect { host.mock_request('**/a', **options) { :continue } }.to raise_error(ArgumentError, /either a block or a response/)
261
+ end
262
+ expect { host.mock_request('**/a') { :continue } }.not_to raise_error
263
+ end
264
+
265
+ it 'rejects a status outside 100..599' do
266
+ expect { host.mock_request('**/a', status: 999) }.to raise_error(ArgumentError, /100..599/)
267
+ expect { host.mock_request('**/a', status: 99) }.to raise_error(ArgumentError)
268
+ end
269
+
270
+ it 'falls back to failRequest when provideResponse raises' do
271
+ host.mock_request('**/api/users', json: {})
272
+ allow(bidi).to receive(:send_cmd).and_wrap_original do |original, method, **params|
273
+ raise 'gone' if method == 'network.provideResponse'
274
+
275
+ original.call(method, **params)
276
+ end
277
+ emit
278
+ expect(bidi.named('network.failRequest')).to eq([{ request: 'r1' }])
279
+ end
280
+
281
+ it 'rejects json and body together' do
282
+ expect { host.mock_request('**/a', json: {}, body: 'x') }.to raise_error(ArgumentError)
283
+ end
284
+
285
+ it 'block_request fails the request' do
286
+ host.block_request('**/api/users')
287
+ emit
288
+ expect(bidi.named('network.failRequest')).to eq([{ request: 'r1' }])
289
+ end
290
+
291
+ it 'continues unmatched requests with only the request id' do
292
+ host.mock_request('**/other')
293
+ emit
294
+ expect(bidi.named('network.continueRequest')).to eq([{ request: 'r1' }])
295
+ end
296
+
297
+ it 'ignores events that are not ours' do
298
+ host.block_request('**/api/users')
299
+ emit(blocked: false)
300
+ emit(intercepts: ['other'])
301
+ expect(bidi.commands.map(&:first)).to eq(['network.addIntercept'])
302
+ end
303
+
304
+ it 'lets the newest mock win' do
305
+ host.mock_request('**/api/users', status: 200)
306
+ host.mock_request('**/api/*', status: 404)
307
+ emit
308
+ expect(bidi.named('network.provideResponse').first).to include(statusCode: 404)
309
+ end
310
+
311
+ it 'filters by method' do
312
+ host.mock_request('**/api/users', method: :post, status: 201)
313
+ emit
314
+ emit(id: 'r2', http_method: 'POST')
315
+ expect(bidi.named('network.continueRequest')).to eq([{ request: 'r1' }])
316
+ expect(bidi.named('network.provideResponse').first).to include(request: 'r2', statusCode: 201)
317
+ end
318
+
319
+ describe 'block form' do
320
+ it 'fulfils with a returned Hash' do
321
+ host.mock_request('**/api/users') { |request| { status: 202, body: request.method } }
322
+ emit
323
+ params = bidi.named('network.provideResponse').first
324
+ expect(params).to include(statusCode: 202)
325
+ expect(params[:body][:value].unpack1('m0')).to eq('GET')
326
+ end
327
+
328
+ it 'continues, and continues with headers merged case-insensitively' do
329
+ host.mock_request('**/a') { :continue }
330
+ host.mock_request('**/b') { { continue: { headers: { 'x-a' => 'b', 'accept' => 'text/html' } } } }
331
+ emit(url: 'https://a.test/a')
332
+ emit(id: 'r2', url: 'https://a.test/b', intercepts: ['i-2'])
333
+ first, second = bidi.named('network.continueRequest')
334
+ expect(first).to eq(request: 'r1')
335
+ expect(second[:request]).to eq('r2')
336
+ expect(header_pairs(second)).to eq('x-a' => 'b', 'accept' => 'text/html')
337
+ end
338
+
339
+ it 'keeps the original headers when continuing with new ones' do
340
+ host.mock_request('**/a') { { continue: { headers: { 'x-a' => 'b' } } } }
341
+ emit(url: 'https://a.test/a')
342
+ expect(header_pairs(bidi.named('network.continueRequest').first)).to eq('Accept' => '*/*', 'x-a' => 'b')
343
+ end
344
+
345
+ it 'fails the request and raises later when the block raises' do
346
+ host.mock_request('**/api/users') { raise ArgumentError, 'boom' }
347
+ emit
348
+ expect(bidi.named('network.failRequest')).to eq([{ request: 'r1' }])
349
+ expect { host.mock_request('**/x') }.to raise_error(NetworkHelper::MockError, %r{api/users raised ArgumentError: boom}) do |error|
350
+ expect(error.cause).to be_a(ArgumentError)
351
+ end
352
+ end
353
+
354
+ it 'hands the error to finish instead when it comes first' do
355
+ host.mock_request('**/api/users') { raise 'boom' }
356
+ emit
357
+ error = NetworkHelper.finish(fake_driver)
358
+ expect(error).to be_a(NetworkHelper::MockError)
359
+ expect(error.cause.message).to eq('boom')
360
+ expect(NetworkHelper.finish(fake_driver)).to be_nil
361
+ end
362
+
363
+ it 'catches non-StandardError exceptions such as failed expectations' do
364
+ custom = Class.new(Exception) { def message = 'expectation not met' } # rubocop:disable Lint/InheritException
365
+ host.mock_request('**/api/users') { raise custom }
366
+ emit
367
+ expect(bidi.named('network.failRequest')).to eq([{ request: 'r1' }])
368
+ expect { host.mock_request('**/x') }.to raise_error(NetworkHelper::MockError, /expectation not met/) do |error|
369
+ expect(error.cause).to be_a(custom)
370
+ end
371
+ end
372
+
373
+ it 'includes the reason when a returned Hash is rejected' do
374
+ host.mock_request('**/api/users') { { json: {}, body: 'x' } }
375
+ emit
376
+ expect { host.clear_mocks }.to raise_error(NetworkHelper::MockError, /returned.*not both/)
377
+ end
378
+
379
+ it 'rejects a returned status out of range' do
380
+ host.mock_request('**/api/users') { { status: 700 } }
381
+ emit
382
+ expect(bidi.named('network.failRequest').size).to eq(1)
383
+ expect { host.clear_mocks }.to raise_error(NetworkHelper::MockError, /returned.*100..599/)
384
+ end
385
+
386
+ it 'treats a junk result as an error' do
387
+ host.mock_request('**/api/users') { 42 }
388
+ emit
389
+ expect(bidi.named('network.failRequest').size).to eq(1)
390
+ expect { host.clear_mocks }.to raise_error(NetworkHelper::MockError, /returned 42/)
391
+ end
392
+
393
+ it 'with a stored error fails a matching request but continues an unmatched one' do
394
+ host.mock_request('https://a.test/api/users') { raise 'boom' }
395
+ emit
396
+ emit(id: 'r2', url: 'https://a.test/other')
397
+ emit(id: 'r3', url: 'https://a.test/api/users')
398
+ expect(bidi.named('network.failRequest').map { |p| p[:request] }).to eq(%w[r1 r3])
399
+ expect(bidi.named('network.continueRequest')).to eq([{ request: 'r2' }])
400
+ end
401
+
402
+ it 'turns a returned Hash with bad types into a MockError' do
403
+ [{ headers: nil }, { headers: 'x' }, { status: 'abc' }, { continue: { headers: 'x' } }].each_with_index do |result, n|
404
+ host.mock_request("**/bad#{n}") { result }
405
+ emit(id: "b#{n}", url: "https://a.test/bad#{n}", intercepts: ['i-1'])
406
+ expect(bidi.named('network.failRequest').map { |p| p[:request] }).to include("b#{n}")
407
+ expect { host.clear_mocks }.to raise_error(NetworkHelper::MockError, /returned/)
408
+ NetworkHelper.finish(fake_driver)
409
+ end
410
+ end
411
+
412
+ it 'keeps only the first error and fails later requests' do
413
+ host.mock_request('**/api/users') { 42 }
414
+ emit
415
+ emit(id: 'r2')
416
+ expect(bidi.named('network.failRequest').size).to eq(2)
417
+ expect { host.remove_mock(nil) }.to raise_error(NetworkHelper::MockError, /returned 42/)
418
+ end
419
+ end
420
+
421
+ it 'answers exactly once per request under concurrency' do
422
+ host.mock_request('**/api/users', json: {})
423
+ host.mock_request('**/slow') { |request| request.url.empty? ? :fail : { status: 204 } }
424
+ threads = Array.new(50) { |n| Thread.new { emit(id: "r#{n}", url: n.even? ? 'https://a.test/api/users' : 'https://a.test/slow', intercepts: ['i-2']) } }
425
+ threads.each(&:join)
426
+ answered = bidi.commands.select { |name, _| name.match?(/provideResponse|failRequest|continueRequest/) }
427
+ expect(answered.size).to eq(50)
428
+ expect(answered.map { |_, params| params[:request] }.uniq.size).to eq(50)
429
+ end
430
+
431
+ describe 'CORS' do
432
+ def sent(pairs)
433
+ pairs.map { |name, value| { 'name' => name, 'value' => { 'type' => 'string', 'value' => value } } }
434
+ end
435
+
436
+ it 'adds CORS headers for a cross-origin request' do
437
+ host.mock_request('**/api/users', json: {})
438
+ emit(headers: sent('Origin' => 'https://app.test'))
439
+ expect(header_pairs(bidi.named('network.provideResponse').first)).to include(
440
+ 'Access-Control-Allow-Origin' => 'https://app.test', 'Access-Control-Allow-Credentials' => 'true'
441
+ )
442
+ end
443
+
444
+ it "uses the Referer's origin when the browser pauses the request before adding Origin (Chrome)" do
445
+ host.mock_request('**/api/users', json: {})
446
+ emit(headers: sent('Referer' => 'https://app.test/some/page'))
447
+ expect(header_pairs(bidi.named('network.provideResponse').first)).to include(
448
+ 'Access-Control-Allow-Origin' => 'https://app.test'
449
+ )
450
+ end
451
+
452
+ it 'sends Allow-Origin * without credentials when no Origin or Referer is known' do
453
+ host.mock_request('**/api/users', json: {})
454
+ emit
455
+ pairs = header_pairs(bidi.named('network.provideResponse').first)
456
+ expect(pairs).to include('Access-Control-Allow-Origin' => '*')
457
+ expect(pairs.keys).not_to include('Access-Control-Allow-Credentials')
458
+ end
459
+
460
+ it 'keeps the mock Allow-Origin when no origin is known' do
461
+ host.mock_request('**/api/users', json: {}, headers: { 'access-control-allow-origin' => 'https://x.test' })
462
+ emit
463
+ expect(header_pairs(bidi.named('network.provideResponse').first).keys).to eq(%w[access-control-allow-origin Content-Type])
464
+ end
465
+
466
+ it 'answers a preflight with * and no credentials when no origin is known' do
467
+ host.mock_request('**/api/users', json: {})
468
+ emit(http_method: 'OPTIONS', headers: sent('Access-Control-Request-Method' => 'GET'))
469
+ pairs = header_pairs(bidi.named('network.provideResponse').first)
470
+ expect(pairs).to include('Access-Control-Allow-Origin' => '*', 'Access-Control-Allow-Methods' => 'GET')
471
+ expect(pairs.keys).not_to include('Access-Control-Allow-Credentials')
472
+ end
473
+
474
+ it 'adds none when the Referer is same-origin' do
475
+ host.mock_request('**/api/users', json: {})
476
+ emit(headers: sent('Referer' => 'https://a.test/page'))
477
+ expect(header_pairs(bidi.named('network.provideResponse').first).keys).to eq(['Content-Type'])
478
+ end
479
+
480
+ it 'adds none for same-origin' do
481
+ host.mock_request('**/api/users', json: {})
482
+ emit(headers: sent('Origin' => 'https://a.test'))
483
+ expect(header_pairs(bidi.named('network.provideResponse').first).keys).to eq(['Content-Type'])
484
+ end
485
+
486
+ it "keeps the mock's own Access-Control-Allow-Origin" do
487
+ host.mock_request('**/api/users', json: {}, headers: { 'access-control-allow-origin' => '*' })
488
+ emit(headers: sent('Origin' => 'https://app.test'))
489
+ pairs = header_pairs(bidi.named('network.provideResponse').first)
490
+ expect(pairs).to include('access-control-allow-origin' => '*', 'Access-Control-Allow-Credentials' => 'true')
491
+ expect(pairs).not_to include('Access-Control-Allow-Origin')
492
+ end
493
+
494
+ it 'answers a preflight with 204 echoing method and headers' do
495
+ mock = host.mock_request('**/api/users', method: 'POST', json: {})
496
+ NetworkHelper.observer = double('observer').tap { |o| expect(o).not_to receive(:request_mocked) }
497
+ emit(http_method: 'OPTIONS', headers: sent('Origin' => 'https://app.test', 'Access-Control-Request-Method' => 'POST',
498
+ 'Access-Control-Request-Headers' => 'content-type'))
499
+ params = bidi.named('network.provideResponse').first
500
+ expect(params).to include(statusCode: 204)
501
+ expect(header_pairs(params)).to eq('Access-Control-Allow-Methods' => 'POST', 'Access-Control-Allow-Headers' => 'content-type',
502
+ 'Access-Control-Allow-Origin' => 'https://app.test',
503
+ 'Access-Control-Allow-Credentials' => 'true')
504
+ expect(mock).not_to be_called
505
+ end
506
+
507
+ it 'omits Allow-Headers when none were requested' do
508
+ host.mock_request('**/api/users', json: {})
509
+ emit(http_method: 'OPTIONS', headers: sent('Origin' => 'https://app.test', 'Access-Control-Request-Method' => 'GET'))
510
+ expect(header_pairs(bidi.named('network.provideResponse').first).keys).not_to include('Access-Control-Allow-Headers')
511
+ end
512
+
513
+ it "a mock with method: 'OPTIONS' answers the preflight itself" do
514
+ host.mock_request('**/api/users', json: {})
515
+ host.mock_request('**/api/users', method: 'OPTIONS', status: 200, body: 'mine')
516
+ emit(http_method: 'OPTIONS', intercepts: ['i-2'],
517
+ headers: sent('Origin' => 'https://app.test', 'Access-Control-Request-Method' => 'GET'))
518
+ params = bidi.named('network.provideResponse').first
519
+ expect(params).to include(statusCode: 200, body: { type: 'base64', value: ['mine'].pack('m0') })
520
+ end
521
+
522
+ it 'preflight to an unmatched URL is continued' do
523
+ host.mock_request('**/api/users', json: {})
524
+ emit(url: 'https://a.test/other', http_method: 'OPTIONS',
525
+ headers: sent('Origin' => 'https://app.test', 'Access-Control-Request-Method' => 'GET'))
526
+ expect(bidi.named('network.provideResponse')).to be_empty
527
+ expect(bidi.named('network.continueRequest').size).to eq(1)
528
+ end
529
+ end
530
+
531
+ it 'records calls, frozen, with lowercase headers' do
532
+ mock = host.mock_request('**/api/users', json: {})
533
+ expect(mock).not_to be_called
534
+ emit
535
+ expect(mock).to be_called
536
+ expect(mock.url).to eq('**/api/users')
537
+ expect(mock.calls.map(&:url)).to eq(['https://a.test/api/users'])
538
+ expect(mock.calls.first.headers).to eq('accept' => '*/*')
539
+ expect(mock.calls).to be_frozen
540
+ expect(mock.calls.first).to be_frozen
541
+ end
542
+
543
+ describe 'observer' do
544
+ let(:seen) { [] }
545
+
546
+ before do
547
+ log = seen
548
+ commands = bidi.commands
549
+ NetworkHelper.observer = Class.new do
550
+ define_method(:request_mocked) do |**event|
551
+ log << [event, commands.map(&:first)]
552
+ end
553
+ end.new
554
+ end
555
+
556
+ it 'is told before the answer' do
557
+ host.mock_request('**/api/users', json: {})
558
+ emit
559
+ event, commands = seen.first
560
+ expect(event).to eq(request_id: 'r1', redirect_count: 0, outcome: 'fulfilled', status: 200)
561
+ expect(commands).not_to include('network.provideResponse')
562
+ end
563
+
564
+ it 'reports failed with no status, and not continue' do
565
+ host.block_request('**/api/users')
566
+ host.mock_request('**/other')
567
+ emit
568
+ emit(id: 'r2', url: 'https://a.test/nothing')
569
+ expect(seen.map(&:first)).to eq([{ request_id: 'r1', redirect_count: 0, outcome: 'failed', status: nil }])
570
+ end
571
+
572
+ it 'does not stop the answer when it raises' do
573
+ NetworkHelper.observer = Class.new do
574
+ def request_mocked(**)
575
+ raise 'nope'
576
+ end
577
+ end.new
578
+ host.mock_request('**/api/users', json: {})
579
+ emit
580
+ expect(bidi.named('network.provideResponse').size).to eq(1)
581
+ end
582
+ end
583
+
584
+ describe 'removal' do
585
+ it 'remove_mock removes that intercept' do
586
+ mock = host.mock_request('**/a')
587
+ host.mock_request('**/b')
588
+ host.remove_mock(mock)
589
+ expect(bidi.named('network.removeIntercept')).to eq([{ intercept: 'i-1' }])
590
+ end
591
+
592
+ it 'finish removes every intercept' do
593
+ host.mock_request('**/a')
594
+ host.mock_request('**/b')
595
+ expect(NetworkHelper.finish(fake_driver)).to be_nil
596
+ expect(bidi.named('network.removeIntercept')).to eq([{ intercept: 'i-1' }, { intercept: 'i-2' }])
597
+ end
598
+
599
+ it 'finish returns nil without a router and never raises' do
600
+ expect(NetworkHelper.finish(fake_driver)).to be_nil
601
+ host.mock_request('**/a')
602
+ bidi.command_failure = RuntimeError.new('gone')
603
+ expect(NetworkHelper.finish(fake_driver)).to be_nil
604
+ end
605
+
606
+ it 'clear_mocks and remove_mock do nothing without a router' do
607
+ expect(host.clear_mocks).to be_nil
608
+ expect(bidi.commands).to be_empty
609
+ end
610
+ end
611
+
612
+ describe 'Unavailable' do
613
+ it 'is raised for safari' do
614
+ allow(fake_driver.capabilities).to receive(:browser_name).and_return('safari')
615
+ expect { host.mock_request('**/a') }.to raise_error(NetworkHelper::Unavailable, /safari/)
616
+ end
617
+
618
+ it 'names BIDI=off' do
619
+ ENV['BIDI'] = 'off'
620
+ expect { host.block_request('**/a') }.to raise_error(NetworkHelper::Unavailable, /BIDI=off/)
621
+ end
622
+
623
+ it 'is raised when subscribe fails' do
624
+ bidi.failure = RuntimeError.new('no socket')
625
+ expect { host.mock_request('**/a') }.to raise_error(NetworkHelper::Unavailable, /chrome.*no socket/)
626
+ end
627
+ end
628
+
629
+ it 'adds exactly four public methods' do
630
+ expect(NetworkHelper.public_instance_methods(false).sort).to eq(%i[block_request clear_mocks mock_request remove_mock])
631
+ expect(NetworkHelper.private_instance_methods(false)).to be_empty
632
+ end
633
+ end
634
+ # rubocop:enable RSpec/MultipleMemoizedHelpers, RSpec/VerifiedDoubles, RSpec/MultipleExpectations