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
@@ -1,7 +1,5 @@
1
- <% if rspec? -%>
2
1
  require:
3
2
  - rubocop-rspec
4
- <% end -%>
5
3
 
6
4
  # Layout
7
5
  Layout/CaseIndentation:
@@ -4,12 +4,20 @@ require 'yaml'
4
4
  <%- if selenium_based? -%>
5
5
  require 'active_support/inflector'
6
6
  require 'selenium-webdriver'
7
+ require 'ruby_raider/runtime'
7
8
  require_relative 'video_helper'
9
+ <%- if network_helper? -%>
10
+ require_relative 'network_helper'
11
+ <%- end -%>
12
+ <%- if screenshot_helper? -%>
13
+ require_relative 'screenshot_helper'
14
+ <%- end -%>
8
15
  <%- if trace_helper? -%>
9
16
  require_relative 'trace_helper'
10
17
  <%- end -%>
11
18
  <%- else -%>
12
19
  require 'appium_lib'
20
+ require 'ruby_raider/runtime'
13
21
  <%- end -%>
14
22
 
15
23
  module DriverHelper
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_raider/runtime'
4
+
5
+ # Swipes and scrolls through the platform's own mobile: commands. Finding and clicking
6
+ # elements is plain Appium; the runtime waits for them.
7
+ #
8
+ # swipe('left', element)
9
+ # scroll('down')
10
+ # scroll_to({ accessibility_id: 'footer' }).click
11
+ module GestureHelper
12
+ # Swipes the element, or the whole screen without one.
13
+ def swipe(direction, element = nil)
14
+ Gesture.new(driver, :swipe, direction).perform(element)
15
+ end
16
+
17
+ # Scrolls the element, or the whole screen without one.
18
+ def scroll(direction, element = nil)
19
+ Gesture.new(driver, :scroll, direction).perform(element)
20
+ end
21
+
22
+ # Scrolls the screen between single attempts to find the element, up to max_scrolls times.
23
+ def scroll_to(locator, direction: 'down', max_scrolls: 10)
24
+ gesture = Gesture.new(driver, :scroll, direction)
25
+ scrolls = 0
26
+ begin
27
+ Raider::Wait.immediately { driver.find_element(locator) }
28
+ rescue Selenium::WebDriver::Error::NoSuchElementError
29
+ if scrolls >= max_scrolls
30
+ raise Selenium::WebDriver::Error::NoSuchElementError,
31
+ "unable to locate #{Raider::Locator.describe([locator])} after #{scrolls} scrolls"
32
+ end
33
+
34
+ gesture.perform(nil)
35
+ scrolls += 1
36
+ retry
37
+ end
38
+ end
39
+
40
+ # A swipe or scroll through the platform's own mobile: command, as appium_lib issues them.
41
+ class Gesture
42
+ DIRECTIONS = %w[up down left right].freeze
43
+ COMMANDS = {
44
+ ios: { swipe: 'mobile: swipe', scroll: 'mobile: scroll' }.freeze,
45
+ android: { swipe: 'mobile: swipeGesture', scroll: 'mobile: scrollGesture' }.freeze
46
+ }.freeze
47
+ PERCENT = 0.75
48
+
49
+ def initialize(driver, kind, direction)
50
+ @driver = driver
51
+ @device = driver.appium_device.to_s.downcase.to_sym
52
+ @command = COMMANDS.dig(@device, kind)
53
+ raise NotImplementedError, "#{kind} has no gesture for #{driver.appium_device.inspect}; only :ios and :android" unless @command
54
+
55
+ @direction = direction.to_s
56
+ raise ArgumentError, "direction must be up, down, left or right; got #{direction.inspect}" unless DIRECTIONS.include?(@direction)
57
+ end
58
+
59
+ # element: the raw element to act on, or nil for the whole screen.
60
+ def perform(element)
61
+ @driver.execute_script(@command, arguments(element))
62
+ end
63
+
64
+ private
65
+
66
+ def arguments(element)
67
+ return { direction: @direction, element: element&.id }.compact if @device == :ios
68
+
69
+ area = element ? { elementId: element.id } : screen
70
+ area.merge(direction: @direction, percent: PERCENT)
71
+ end
72
+
73
+ def screen
74
+ rect = @driver.window_rect
75
+ { left: rect.x, top: rect.y, width: rect.width, height: rect.height }
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,521 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'uri'
5
+
6
+ # Mocks network requests in the browser through WebDriver BiDi (chrome, edge and firefox):
7
+ # answer a request with your own response, or block it.
8
+ # BIDI=off turns BiDi off, for a grid that cannot carry the websocket.
9
+ module NetworkHelper
10
+ # Raised when mocking cannot run: no BiDi, or a browser without it.
11
+ class Unavailable < StandardError; end
12
+
13
+ # Raised for a mock that cannot be set up: a bad pattern or an invalid response.
14
+ class MockError < StandardError; end
15
+
16
+ # Matches a request against a glob or Regexp URL and an optional HTTP method.
17
+ # Glob: ** is any characters, * is any characters except /, everything else is literal.
18
+ class Matcher
19
+ def initialize(url, method: nil)
20
+ raise ArgumentError, "url must be a String or Regexp, got #{url.class}" unless url.is_a?(String) || url.is_a?(Regexp)
21
+
22
+ @url = url
23
+ @method = method&.to_s&.upcase
24
+ @regexp = url.is_a?(Regexp) ? url : glob_to_regexp(url)
25
+ end
26
+
27
+ def match?(url_string, http_method)
28
+ return false if @method && @method != http_method.to_s.upcase
29
+
30
+ match_url?(url_string)
31
+ end
32
+
33
+ def match_url?(url_string)
34
+ @regexp.match?(url_string.to_s)
35
+ end
36
+
37
+ def method?(http_method)
38
+ @method == http_method.to_s.upcase
39
+ end
40
+
41
+ # BiDi urlPatterns narrowing the intercept to the literal parts of the glob, or nil to pause everything.
42
+ def intercept_patterns
43
+ return nil if @url.is_a?(Regexp)
44
+
45
+ pattern = literal_parts.compact
46
+ pattern.empty? ? nil : [{ type: 'pattern' }.merge(pattern)]
47
+ end
48
+
49
+ # Characters a BiDi URL pattern pathname accepts as literals; anything else is left to the matcher.
50
+ SAFE_PATH = %r{\A[A-Za-z0-9._~!$&'+,;=:@/%-]*\z}.freeze
51
+
52
+ private
53
+
54
+ def glob_to_regexp(glob)
55
+ source = glob.split(/(\*\*|\*)/).map do |token|
56
+ case token
57
+ when '**' then '.*'
58
+ when '*' then '[^/]*'
59
+ else Regexp.escape(token)
60
+ end
61
+ end
62
+ Regexp.new("\\A#{source.join}\\z", Regexp::MULTILINE)
63
+ end
64
+
65
+ # Only a glob that opens with a literal scheme://host[:port] narrows: anything else could match a
66
+ # URL the pattern would miss. The path counts only right after such an origin.
67
+ def literal_parts
68
+ glob = @url.split(/[?#]/, 2).first.to_s
69
+ scheme = glob[%r{\A([a-z][a-z0-9+.-]*)://}i, 1]
70
+ return {} unless scheme
71
+
72
+ hostport, path = glob.delete_prefix("#{scheme}://").split(%r{(?=/)}, 2)
73
+ return {} if hostport.to_s.match?(/[\[@]/)
74
+
75
+ host, port = hostport.to_s.split(':', 2)
76
+ return {} if [host, port].compact.any? { |part| part.empty? || part.include?('*') }
77
+
78
+ { protocol: scheme.downcase, hostname: host, port: port, pathname: literal_path(path) }.compact
79
+ end
80
+
81
+ def literal_path(path)
82
+ path if path&.match?(SAFE_PATH)
83
+ end
84
+ end
85
+
86
+ # The driver helper sets web_socket_url for these.
87
+ BIDI_BROWSERS = %w[chrome edge firefox].freeze
88
+
89
+ REASON_PHRASES = {
90
+ 200 => 'OK', 201 => 'Created', 204 => 'No Content', 301 => 'Moved Permanently', 302 => 'Found',
91
+ 304 => 'Not Modified', 400 => 'Bad Request', 401 => 'Unauthorized', 403 => 'Forbidden',
92
+ 404 => 'Not Found', 409 => 'Conflict', 422 => 'Unprocessable Entity', 429 => 'Too Many Requests',
93
+ 500 => 'Internal Server Error', 502 => 'Bad Gateway', 503 => 'Service Unavailable',
94
+ 504 => 'Gateway Timeout'
95
+ }.freeze
96
+
97
+ # A request the browser paused: lowercase header names, values as strings. Frozen.
98
+ RaiderRequest = Struct.new(:method, :url, :headers) # rubocop:disable Lint/StructNewOverride
99
+
100
+ # What a mock answered or will answer. calls holds the requests it handled, oldest first.
101
+ class RaiderMock
102
+ attr_reader :url, :matcher, :response, :block
103
+
104
+ def initialize(url, matcher, response: nil, block: nil)
105
+ @url = url
106
+ @matcher = matcher
107
+ @response = response
108
+ @block = block
109
+ @calls = []
110
+ @lock = Mutex.new
111
+ end
112
+
113
+ def calls
114
+ @lock.synchronize { @calls.dup.freeze }
115
+ end
116
+
117
+ def called?
118
+ @lock.synchronize { !@calls.empty? }
119
+ end
120
+
121
+ def record(request)
122
+ @lock.synchronize { @calls << request }
123
+ end
124
+ end
125
+
126
+ # One per driver. Holds the mocks and answers paused requests on Selenium's callback threads.
127
+ # All state sits behind one Mutex; a mock_request block runs outside it.
128
+ class Router
129
+ EVENT = 'network.beforeRequestSent'
130
+ FATAL = [Interrupt, SystemExit, NoMemoryError].freeze
131
+
132
+ def initialize(bidi)
133
+ @bidi = bidi
134
+ @lock = Mutex.new
135
+ @mocks = []
136
+ @intercepts = {}
137
+ @ids = []
138
+ @error = nil
139
+ bidi.session.subscribe(EVENT)
140
+ bidi.add_callback(EVENT) { |params| handle(params) }
141
+ end
142
+
143
+ def add(mock)
144
+ @lock.synchronize do
145
+ params = { phases: ['beforeRequestSent'] }
146
+ patterns = mock.matcher.intercept_patterns
147
+ params[:urlPatterns] = patterns if patterns
148
+ id = @bidi.send_cmd('network.addIntercept', **params)['intercept']
149
+ @mocks << mock
150
+ @intercepts[mock] = id
151
+ @ids << id
152
+ end
153
+ mock
154
+ end
155
+
156
+ def remove(mock)
157
+ id = @lock.synchronize do
158
+ @mocks.delete(mock)
159
+ @intercepts.delete(mock)
160
+ end
161
+ remove_intercept(id) if id
162
+ nil
163
+ end
164
+
165
+ def clear
166
+ ids = @lock.synchronize do
167
+ @mocks.clear
168
+ @intercepts.values.tap { @intercepts.clear }
169
+ end
170
+ ids.each { |id| remove_intercept(id) }
171
+ nil
172
+ end
173
+
174
+ def raise_error!
175
+ error = @lock.synchronize { @error }
176
+ raise error if error
177
+ end
178
+
179
+ # Removes every intercept and hands back the stored error, if any. Never raises.
180
+ def finish
181
+ ids, error = @lock.synchronize do
182
+ taken = [@intercepts.values, @error]
183
+ @mocks.clear
184
+ @intercepts.clear
185
+ @error = nil
186
+ taken
187
+ end
188
+ ids.each { |id| remove_intercept(id) }
189
+ error
190
+ rescue StandardError
191
+ nil
192
+ end
193
+
194
+ private
195
+
196
+ def remove_intercept(id)
197
+ @bidi.send_cmd('network.removeIntercept', intercept: id)
198
+ rescue StandardError
199
+ nil
200
+ end
201
+
202
+ def handle(params)
203
+ return unless params['isBlocked'] && ours?(params['intercepts'])
204
+
205
+ answer = [:fail]
206
+ begin
207
+ answer = decide(params['request'] || {})
208
+ rescue StandardError => e
209
+ store_error(e)
210
+ ensure
211
+ deliver(params, answer)
212
+ end
213
+ end
214
+
215
+ def ours?(ids)
216
+ @lock.synchronize { Array(ids).any? { |id| @ids.include?(id) } }
217
+ end
218
+
219
+ def store_error(error)
220
+ @lock.synchronize { @error = error if @error.nil? }
221
+ end
222
+
223
+ def decide(raw)
224
+ request = build_request(raw)
225
+ mocks, error = @lock.synchronize { [@mocks.reverse, @error] }
226
+ mock = mocks.find { |m| m.matcher.match?(request.url, request.method) }
227
+ preflight = preflight?(request, mocks)
228
+ return (mock || preflight ? [:fail] : [:continue, nil]) if error
229
+ return preflight_answer(request) if preflight
230
+ return [:continue, nil] unless mock
231
+
232
+ mock.record(request)
233
+ answer = mock.block ? from_block(mock, request, raw['headers']) : [:fulfil, mock.response]
234
+ answer.first == :fulfil ? [:fulfil, with_cors(request, answer.last)] : answer
235
+ end
236
+
237
+ # An OPTIONS request asking for CORS permission to a URL some mock covers, with no mock of
238
+ # its own for OPTIONS: the router answers it. It is neither recorded nor reported.
239
+ def preflight?(request, mocks)
240
+ return false unless request.method.upcase == 'OPTIONS' && request.headers.key?('access-control-request-method')
241
+
242
+ covering = mocks.select { |m| m.matcher.match_url?(request.url) }
243
+ covering.any? && covering.none? { |m| m.matcher.method?('OPTIONS') }
244
+ end
245
+
246
+ def preflight_answer(request)
247
+ pairs = [['Access-Control-Allow-Methods', request.headers['access-control-request-method']]]
248
+ requested = request.headers['access-control-request-headers']
249
+ pairs << ['Access-Control-Allow-Headers', requested] if requested
250
+ [:preflight, with_cors(request, { status: 204, headers: pairs, body: '' }, force: true)]
251
+ end
252
+
253
+ # Adds Allow-Origin and Allow-Credentials for a cross-origin request unless the mock set them.
254
+ def with_cors(request, response, force: false)
255
+ origin = request_origin(request)
256
+ return response if origin && !(force || cross_origin?(origin, request.url))
257
+
258
+ headers = response[:headers].dup
259
+ # Without a known origin (Referrer-Policy no-referrer) a wildcard passes, but never with credentials.
260
+ allowed = { 'Access-Control-Allow-Origin' => origin || '*' }
261
+ allowed['Access-Control-Allow-Credentials'] = 'true' if origin
262
+ allowed.each do |name, value|
263
+ headers << [name, value] if headers.none? { |existing, _| existing.casecmp?(name) }
264
+ end
265
+ response.merge(headers: headers)
266
+ end
267
+
268
+ # Chrome adds the Origin header after the request is paused, so it is missing here: the Referer's
269
+ # origin stands in for it.
270
+ def request_origin(request)
271
+ request.headers['origin'] || referer_origin(request.headers['referer'])
272
+ end
273
+
274
+ def referer_origin(referer)
275
+ return nil unless referer
276
+
277
+ uri = URI(referer)
278
+ "#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless uri.port == uri.default_port}" if uri.host
279
+ rescue URI::Error
280
+ nil
281
+ end
282
+
283
+ def cross_origin?(origin, url)
284
+ uri = URI(url)
285
+ own = "#{uri.scheme}://#{uri.host}#{":#{uri.port}" unless uri.port == uri.default_port}"
286
+ origin.downcase != own.downcase
287
+ rescue URI::Error
288
+ false
289
+ end
290
+
291
+ def build_request(raw)
292
+ headers = Array(raw['headers']).each_with_object({}) do |header, all|
293
+ all[header['name'].to_s.downcase] = header_value(header)
294
+ end
295
+ RaiderRequest.new(raw['method'].to_s, raw['url'].to_s, headers.freeze).freeze
296
+ end
297
+
298
+ def header_value(header)
299
+ value = header['value']
300
+ value.is_a?(Hash) ? value['value'] : value
301
+ end
302
+
303
+ def from_block(mock, request, raw_headers)
304
+ result = run_block(mock, request)
305
+ case result
306
+ when :fail then [:fail]
307
+ when :continue then [:continue, nil]
308
+ when Hash then from_hash(mock, result, raw_headers)
309
+ else junk(mock, result)
310
+ end
311
+ end
312
+
313
+ def run_block(mock, request)
314
+ mock.block.call(request)
315
+ rescue Exception => e # rubocop:disable Lint/RescueException
316
+ # An expectation failure inside the block is an Exception, not a StandardError: it must not
317
+ # vanish on Selenium's callback thread. Fatal ones fail the request (ensure) and propagate.
318
+ raise if FATAL.any? { |fatal| e.is_a?(fatal) }
319
+
320
+ raise MockError, "mock_request block for #{mock.url} raised #{e.class}: #{e.message}"
321
+ end
322
+
323
+ def from_hash(mock, result, raw_headers)
324
+ return continue_with(mock, result, raw_headers) if result.key?(:continue)
325
+
326
+ [:fulfil, NetworkHelper.build_response(**result)]
327
+ rescue ArgumentError, TypeError, NoMethodError => e
328
+ junk(mock, result, e.message)
329
+ end
330
+
331
+ def continue_with(mock, result, raw_headers)
332
+ options = result[:continue]
333
+ return junk(mock, result) unless result.size == 1 && options.is_a?(Hash) && (options.keys - [:headers]).empty?
334
+
335
+ [:continue, NetworkHelper.merge_headers(raw_headers, options[:headers] || {})]
336
+ end
337
+
338
+ def junk(mock, result, reason = nil)
339
+ raise MockError, ["mock_request block for #{mock.url} returned #{result.inspect}", reason].compact.join(': ')
340
+ end
341
+
342
+ def deliver(params, answer)
343
+ request_id = params['request']['request']
344
+ kind, detail = answer
345
+ case kind
346
+ when :fulfil then respond(params, request_id, detail)
347
+ when :preflight then provide(request_id, detail)
348
+ when :fail then fail_request(params, request_id)
349
+ else continue_request(request_id, detail)
350
+ end
351
+ rescue StandardError
352
+ nil
353
+ end
354
+
355
+ def respond(params, request_id, response)
356
+ notify(params, request_id, 'fulfilled', response[:status])
357
+ provide(request_id, response)
358
+ end
359
+
360
+ def provide(request_id, response)
361
+ @bidi.send_cmd('network.provideResponse', request: request_id, statusCode: response[:status],
362
+ reasonPhrase: REASON_PHRASES.fetch(response[:status], 'OK'),
363
+ headers: NetworkHelper.header_list(response[:headers]),
364
+ body: { type: 'base64', value: [response[:body]].pack('m0') })
365
+ rescue StandardError
366
+ # The page must never hang on a paused request: fall back to a network error.
367
+ begin
368
+ @bidi.send_cmd('network.failRequest', request: request_id)
369
+ rescue StandardError
370
+ nil
371
+ end
372
+ end
373
+
374
+ def fail_request(params, request_id)
375
+ notify(params, request_id, 'failed', nil)
376
+ @bidi.send_cmd('network.failRequest', request: request_id)
377
+ end
378
+
379
+ def continue_request(request_id, headers)
380
+ args = { request: request_id }
381
+ args[:headers] = headers if headers
382
+ @bidi.send_cmd('network.continueRequest', **args)
383
+ end
384
+
385
+ def notify(params, request_id, outcome, status)
386
+ NetworkHelper.observer&.request_mocked(request_id: request_id, redirect_count: params['redirectCount'].to_i,
387
+ outcome: outcome, status: status)
388
+ rescue StandardError
389
+ nil
390
+ end
391
+ end
392
+
393
+ ROUTERS = ObjectSpace::WeakMap.new
394
+ ROUTERS_LOCK = Mutex.new
395
+
396
+ class << self
397
+ # Follows the mocks: responds to #request_mocked(request_id:, redirect_count:, outcome:, status:)
398
+ # when set. Called before a request is answered. Nil by default.
399
+ attr_accessor :observer
400
+
401
+ def bidi_off?
402
+ ENV['BIDI'].to_s.downcase == 'off'
403
+ end
404
+
405
+ # True for a browser that speaks WebDriver BiDi (Edge reports MicrosoftEdge), unless BIDI=off.
406
+ def bidi?(browser)
407
+ return false if bidi_off?
408
+
409
+ name = browser.to_s.downcase.delete_prefix('microsoft')
410
+ BIDI_BROWSERS.any? { |bidi_browser| name.start_with?(bidi_browser) }
411
+ end
412
+
413
+ # The router for this driver, created on first use. Raises Unavailable without BiDi.
414
+ # ROUTERS holds its values weakly; the router stays alive because the block registered with
415
+ # bidi.add_callback in Router#initialize captures it, and the driver's bidi holds that block.
416
+ def router_for(driver)
417
+ ROUTERS_LOCK.synchronize { ROUTERS[driver] ||= create_router(driver) }
418
+ end
419
+
420
+ # Removes the driver's intercepts and returns the stored MockError, or nil. Never raises.
421
+ def finish(driver)
422
+ router = ROUTERS_LOCK.synchronize { ROUTERS[driver] }
423
+ router&.finish
424
+ rescue StandardError
425
+ nil
426
+ end
427
+
428
+ # Adds the mock to the driver's router, raising a stored MockError first.
429
+ def register(driver, mock)
430
+ router = router_for(driver)
431
+ router.raise_error!
432
+ router.add(mock)
433
+ end
434
+
435
+ # The router if one exists; never creates one.
436
+ def existing_router(driver)
437
+ ROUTERS_LOCK.synchronize { ROUTERS[driver] }
438
+ end
439
+
440
+ def build_response(status: 200, json: nil, body: nil, headers: {}, content_type: nil)
441
+ raise ArgumentError, 'pass json: or body:, not both' unless json.nil? || body.nil?
442
+
443
+ list = headers.map { |name, value| [name.to_s, value.to_s] }
444
+ list = list.reject { |name, _| name.casecmp?('content-type') } << ['Content-Type', content_type.to_s] if content_type
445
+ list << ['Content-Type', 'application/json'] if !json.nil? && list.none? { |name, _| name.casecmp?('content-type') }
446
+ { status: valid_status(status), headers: list, body: (json.nil? ? body.to_s : JSON.generate(json)) }
447
+ end
448
+
449
+ def valid_status(status)
450
+ code = Integer(status)
451
+ raise ArgumentError, "status must be 100..599, got #{code}" unless (100..599).cover?(code)
452
+
453
+ code
454
+ end
455
+
456
+ # The request's own headers with these replacing same-named ones, as a BiDi header list.
457
+ def merge_headers(raw_headers, changes)
458
+ names = changes.keys.map { |name| name.to_s.downcase }
459
+ kept = Array(raw_headers).reject { |header| names.include?(header['name'].to_s.downcase) }
460
+ kept = kept.map do |header|
461
+ value = header['value'].is_a?(Hash) ? header['value'] : { 'type' => 'string', 'value' => header['value'] }
462
+ { name: header['name'], value: { type: value['type'], value: value['value'] } }
463
+ end
464
+ kept + header_list(changes.map { |name, value| [name.to_s, value.to_s] })
465
+ end
466
+
467
+ def header_list(pairs)
468
+ pairs.map { |name, value| { name: name, value: { type: 'string', value: value } } }
469
+ end
470
+
471
+ private
472
+
473
+ def create_router(driver)
474
+ raise Unavailable, 'network mocking is off: BIDI=off is set' if bidi_off?
475
+
476
+ browser = driver.capabilities.browser_name
477
+ raise Unavailable, "network mocking needs WebDriver BiDi, which #{browser.inspect} does not support" unless bidi?(browser)
478
+
479
+ begin
480
+ Router.new(driver.bidi)
481
+ rescue StandardError => e
482
+ raise Unavailable, "network mocking needs WebDriver BiDi, but #{browser} refused it: #{e.class}: #{e.message}"
483
+ end
484
+ end
485
+ end
486
+
487
+ # Answers matching requests with your own response; the request never reaches the server.
488
+ # Pass a block instead to decide per request: it gets a RaiderRequest and returns a Hash of
489
+ # response options, :fail, :continue, or { continue: { headers: { 'x-a' => 'b' } } }.
490
+ def mock_request(url, status: 200, json: nil, body: nil, headers: {}, content_type: nil, method: nil, &block)
491
+ matcher = Matcher.new(url, method: method)
492
+ response = nil
493
+ given = status != 200 || !json.nil? || !body.nil? || !headers.empty? || content_type
494
+ raise ArgumentError, 'give either a block or a response, not both' if block && given
495
+
496
+ unless block
497
+ response = NetworkHelper.build_response(status: status, json: json, body: body,
498
+ headers: headers, content_type: content_type)
499
+ end
500
+ NetworkHelper.register(driver, RaiderMock.new(url, matcher, response: response, block: block))
501
+ end
502
+
503
+ # Matching requests fail with a network error.
504
+ def block_request(url, method: nil)
505
+ mock_request(url, method: method) { :fail }
506
+ end
507
+
508
+ # Stops answering for this mock and removes its intercept.
509
+ def remove_mock(mock)
510
+ router = NetworkHelper.existing_router(driver)
511
+ router&.raise_error!
512
+ router&.remove(mock)
513
+ end
514
+
515
+ # Removes every mock on this driver.
516
+ def clear_mocks
517
+ router = NetworkHelper.existing_router(driver)
518
+ router&.raise_error!
519
+ router&.clear
520
+ end
521
+ end
@@ -1,7 +1,5 @@
1
1
  <% if selenium_based? %>
2
2
  driver.quit
3
- <% elsif watir? %>
4
- browser.quit
5
3
  <% else %>
6
4
  driver.quit_driver
7
5
  <% end %>
@@ -1,7 +1,5 @@
1
1
  <% if selenium_based? %>
2
2
  driver.save_screenshot(screenshot_path)
3
- <% elsif watir? %>
4
- browser.screenshot.save(screenshot_path)
5
3
  <% else %>
6
4
  driver.screenshot(screenshot_path)
7
5
  <% end %>
@@ -26,7 +26,10 @@
26
26
  browser = load_browser == 'ie' ? load_browser.upcase : load_browser.capitalize
27
27
  options = "Selenium::WebDriver::#{browser}::Options".constantize.new
28
28
  browser_arguments(*opts).each { |arg| options.add_argument(arg) }
29
- # WebDriver BiDi carries the trace's network and console (chrome, edge and firefox).
30
- options.web_socket_url = true if defined?(TraceHelper) && TraceHelper.bidi?(load_browser)
29
+ # WebDriver BiDi carries the trace's network and console and the network mocks (chrome, edge and firefox).
30
+ # BIDI=off turns it off for a grid that cannot carry the websocket.
31
+ bidi = (defined?(TraceHelper) && TraceHelper.bidi?(load_browser)) ||
32
+ (defined?(NetworkHelper) && NetworkHelper.bidi?(load_browser))
33
+ options.web_socket_url = true if bidi
31
34
  options
32
35
  end