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,611 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'selenium-webdriver'
4
- require 'yaml'
5
- require_relative 'raider_element'
6
-
7
- <%- if mobile? -%>
8
- # Waits before it acts. Each method takes a locator ({ accessibility_id: 'name' },
9
- # with or without braces, or { ios: {...}, android: {...} }), a RaiderElement or a
10
- # raw element, polls every 0.1s until the element is ready, then acts on it:
11
- # tap({ accessibility_id: 'login' })
12
- # send_keys({ accessibility_id: 'user' }, 'admin')
13
- # swipe('left', { accessibility_id: 'card' })
14
- # scroll_to({ accessibility_id: 'footer' })
15
- # eventually { expect(text(accessibility_id: 'welcome')).to eq('Hi') }
16
- <%- else -%>
17
- # Waits before it acts. Each method takes a locator ({ id: 'name' }, with or
18
- # without braces), a RaiderElement or a raw element, polls every 0.1s until the
19
- # element is ready, then calls the Selenium method of the same name:
20
- # click({ id: 'login' })
21
- # send_keys({ id: 'user' }, 'admin')
22
- # eventually { expect(text(css: '.welcome')).to eq('Hi') }
23
- <%- end -%>
24
- # The wait is config/config.yml `timeout` seconds, or `timeout:` for one call.
25
- module WaitHelper
26
- class TimeoutError < Selenium::WebDriver::Error::TimeoutError; end
27
-
28
- CONFIG_FILE = 'config/config.yml'
29
- DEFAULT_TIMEOUT = 10
30
-
31
- class << self
32
- # Follows every action and eventually when set (the trace helper does). Optional:
33
- # nothing is called on it while nil, and one that raises is dropped.
34
- attr_accessor :observer
35
-
36
- # Calls the observer's hook, or returns nil without one. A broken observer must
37
- # never fail a test: it is warned about once and removed.
38
- # A block builds the details, so computing them is covered by the same rescue.
39
- # $stderr.puts, not Kernel#warn: warn is silenced under -W0.
40
- # rubocop:disable Style/StderrPuts
41
- def report(hook, *args, **details)
42
- current = observer
43
- return unless current
44
-
45
- details = yield if block_given?
46
- current.public_send(hook, *args, **details)
47
- rescue StandardError => e
48
- $stderr.puts("[trace] observer stopped: #{e.class}: #{e.message}")
49
- self.observer = nil
50
- end
51
- # rubocop:enable Style/StderrPuts
52
- end
53
-
54
- def self.default_timeout
55
- @default_timeout ||= begin
56
- config = File.exist?(CONFIG_FILE) ? YAML.load_file(CONFIG_FILE) : nil
57
- (config.is_a?(Hash) && config['timeout']) || DEFAULT_TIMEOUT
58
- end
59
- end
60
-
61
- def self.element(driver, locator, by = {})
62
- RaiderElement.new(driver, Lookup.for(driver, locator, by))
63
- end
64
-
65
- <%- if mobile? -%>
66
- # Swipes or scrolls the element, or the whole screen without a locator.
67
- def self.gesture(driver, kind, direction, locator, timeout)
68
- return element(driver, locator).public_send(kind, direction, timeout: timeout) if locator
69
-
70
- gesture = Gesture.new(driver, kind, direction)
71
- Waiter.new(driver, timeout: timeout).perform_action(kind, Lookup.screen(driver), Waiter::PRESENT) do
72
- gesture.perform(nil)
73
- end
74
- nil
75
- end
76
-
77
- <%- end -%>
78
- def find_element(locator = nil, visible: true, timeout: nil, **by)
79
- Waiter.new(driver, timeout: timeout).find_element(Lookup.for(driver, locator, by), visible)
80
- end
81
-
82
- def find_elements(locator = nil, timeout: nil, **by)
83
- Waiter.new(driver, timeout: timeout).find_elements(Lookup.for(driver, locator, by))
84
- end
85
-
86
- <%- if mobile? -%>
87
- %i[click clear text displayed? enabled? selected?].each do |action|
88
- <%- else -%>
89
- %i[click clear submit hover scroll_into_view text displayed? enabled? selected?].each do |action|
90
- <%- end -%>
91
- define_method(action) do |locator = nil, timeout: nil, **by|
92
- WaitHelper.element(driver, locator, by).public_send(action, timeout: timeout)
93
- end
94
- end
95
-
96
- <%- if mobile? -%>
97
- # tap is Appium's word for click and waits the same way. With a block and no
98
- # locator it stays Ruby's Object#tap.
99
- def tap(locator = nil, timeout: nil, **by, &block)
100
- return super(&block) if block && locator.nil? && by.empty?
101
-
102
- WaitHelper.element(driver, locator, by).tap(timeout: timeout)
103
- end
104
-
105
- <%- end -%>
106
- def send_keys(locator, *keys, timeout: nil)
107
- WaitHelper.element(driver, locator).send_keys(*keys, timeout: timeout)
108
- end
109
-
110
- <%- unless mobile? -%>
111
- def select_by(locator, how, what, timeout: nil)
112
- WaitHelper.element(driver, locator).select_by(how, what, timeout: timeout)
113
- end
114
-
115
- <%- end -%>
116
- def attribute(locator, name, timeout: nil)
117
- WaitHelper.element(driver, locator).attribute(name, timeout: timeout)
118
- end
119
-
120
- <%- if mobile? -%>
121
- # direction: 'up', 'down', 'left' or 'right'.
122
- def swipe(direction, locator = nil, timeout: nil)
123
- WaitHelper.gesture(driver, :swipe, direction, locator, timeout)
124
- end
125
-
126
- def scroll(direction, locator = nil, timeout: nil)
127
- WaitHelper.gesture(driver, :scroll, direction, locator, timeout)
128
- end
129
-
130
- # Scrolls the screen until the element is visible, then returns its RaiderElement.
131
- def scroll_to(locator, direction: 'down', max_scrolls: 10, timeout: nil)
132
- WaitHelper.element(driver, locator).scroll_to(direction: direction, max_scrolls: max_scrolls, timeout: timeout)
133
- end
134
- <%- else -%>
135
- def drag_and_drop(source, target, timeout: nil)
136
- WaitHelper.element(driver, source).drag_and_drop(target, timeout: timeout)
137
- end
138
- <%- end -%>
139
-
140
- def eventually(timeout: nil, &block)
141
- Waiter.new(nil, timeout: timeout).eventually(&block)
142
- end
143
-
144
- # How an element is found: a locator in a scope (the driver or a parent
145
- # Lookup), or an element given as is. The found element is cached until reset.
146
- class Lookup
147
- OS_KEYS = %i[android ios].freeze
148
- PASSED_HINT = 'it was passed in as an element, so it cannot be found again; ' \
149
- "pass its locator (for example { id: 'name' }) instead"
150
- MEMBER_HINT = 'it came from find_elements, which keeps no locator per element; call find_elements again to re-query'
151
-
152
- attr_reader :hint
153
-
154
- # Selenium's aliases for the locator strategies, as the trace names them.
155
- STRATEGIES = { css_selector: 'css', class: 'class_name', link: 'link_text', partial_link: 'partial_link_text' }.freeze
156
-
157
- def self.for(driver, locator, by, scope: driver)
158
- raise ArgumentError, 'pass the locator in braces or as keywords, not both' if locator && !by.empty?
159
-
160
- given = locator || by
161
- # A wrapper keeps how it was found, so it can still re-find itself.
162
- return given.__send__(:lookup) if given.is_a?(RaiderElement)
163
- return new(scope, element: given, hint: PASSED_HINT) if !given.is_a?(Hash) && given.respond_to?(:displayed?)
164
-
165
- new(scope, locator: normalize(driver, given))
166
- end
167
- <%- if mobile? -%>
168
-
169
- # No locator and no element: a whole-screen gesture, which locates nothing.
170
- def self.screen(driver)
171
- new(driver)
172
- end
173
- <%- end -%>
174
-
175
- def self.normalize(driver, given)
176
- locator = symbolize(given)
177
- per_os = !locator.empty? && (locator.keys - OS_KEYS).empty?
178
- locator = symbolize(locator.fetch(driver.appium_device.to_s.downcase.to_sym, nil), given) if per_os
179
- return locator if locator.size == 1
180
-
181
- raise ArgumentError, "a locator is a Hash with one key, such as { id: 'name' }; got #{given.inspect}"
182
- end
183
-
184
- def self.symbolize(hash, given = hash)
185
- return hash.transform_keys(&:to_sym) if hash.is_a?(Hash)
186
-
187
- raise ArgumentError, "a locator is a Hash with one key, such as { id: 'name' }; got #{given.inspect}"
188
- end
189
-
190
- def initialize(scope, locator: nil, element: nil, hint: nil, many: false)
191
- @scope = scope
192
- @locator = locator
193
- @element = element
194
- @last_found = element
195
- @hint = hint
196
- @many = many
197
- @scope_stale = false
198
- end
199
-
200
- # The last element found. A reset only drops the re-find cache, so a wrapper
201
- # keeps its element (and its == and hash) after a timed-out wait.
202
- def element
203
- @element || @last_found
204
- end
205
-
206
- def many
207
- Lookup.new(@scope, locator: @locator, element: @element, many: true)
208
- end
209
-
210
- def locate
211
- return find_all if @many
212
-
213
- return @element if @element || !@locator
214
-
215
- @element = @last_found = in_scope { |context| context.find_element(**@locator) }
216
- end
217
-
218
- # A stale element may mean its parent was re-rendered too, so stale resets the chain.
219
- def reset(stale: false)
220
- @element = nil if @locator
221
- @scope_stale = false
222
- @scope.reset(stale: true) if stale && @scope.is_a?(Lookup)
223
- end
224
-
225
- # After a stale error: the Lookup up the chain whose element went stale and cannot be
226
- # found again (passed in, or from find_elements), or nil when re-finding can heal it.
227
- def lost
228
- return self unless @locator
229
-
230
- @scope.lost if @scope_stale
231
- end
232
-
233
- # { strategy:, selector: } for the observer, or nil when this locates nothing.
234
- def trace_locator
235
- return unless @locator
236
-
237
- strategy, selector = @locator.first
238
- { strategy: STRATEGIES.fetch(strategy) { strategy.to_s }, selector: selector }
239
- end
240
-
241
- # The locators this one is searched inside, outermost first.
242
- def trace_within
243
- return [] unless @scope.is_a?(Lookup)
244
-
245
- [*@scope.trace_within, @scope.trace_locator].compact
246
- end
247
-
248
- def describe
249
- return 'the element' unless @locator
250
-
251
- "{#{@locator.map { |key, value| "#{key}: #{value.inspect}" }.join(', ')}}"
252
- end
253
-
254
- private
255
-
256
- # A stale error while searching inside the parent means the parent, or one above it, went stale.
257
- def in_scope
258
- return yield(@scope) unless @scope.is_a?(Lookup)
259
-
260
- begin
261
- yield(@scope.locate)
262
- rescue Selenium::WebDriver::Error::StaleElementReferenceError
263
- @scope_stale = true
264
- raise
265
- end
266
- end
267
-
268
- def find_all
269
- return [@element] unless @locator
270
-
271
- found = in_scope { |context| context.find_elements(**@locator) }
272
- raise Selenium::WebDriver::Error::NoSuchElementError, 'no match' if found.empty?
273
-
274
- found
275
- end
276
- end
277
-
278
- # Polls until a Lookup is ready, then yields the raw element. perform_action
279
- # is the one choke point every public method (bar eventually) goes through.
280
- class Waiter
281
- POLL_INTERVAL = 0.1
282
- DEADLINE_KEY = :raider_wait_deadline
283
- VISIBLE_ENABLED = %i[visible enabled].freeze
284
- PRESENT = [].freeze
285
- <%- if mobile? -%>
286
- CLICK = %i[visible enabled stable].freeze
287
- VISIBLE = %i[visible].freeze
288
- CHECKS = {
289
- click: CLICK, tap: CLICK,
290
- send_keys: VISIBLE_ENABLED, clear: VISIBLE_ENABLED,
291
- swipe: VISIBLE, scroll: VISIBLE, scroll_to: VISIBLE,
292
- text: PRESENT, attribute: PRESENT, displayed?: PRESENT, enabled?: PRESENT, selected?: PRESENT
293
- }.freeze
294
- <%- else -%>
295
- CHECKS = {
296
- click: %i[visible enabled stable].freeze,
297
- send_keys: VISIBLE_ENABLED, clear: VISIBLE_ENABLED, submit: VISIBLE_ENABLED, select_by: VISIBLE_ENABLED,
298
- hover: %i[visible stable].freeze,
299
- drag_and_drop: %i[visible enabled stable].freeze,
300
- scroll_into_view: PRESENT, text: PRESENT, attribute: PRESENT,
301
- displayed?: PRESENT, enabled?: PRESENT, selected?: PRESENT
302
- }.freeze
303
- <%- end -%>
304
- TARGET_CHECKS = %i[visible].freeze
305
- STALE = 'stale element'
306
- RETRIED = [Selenium::WebDriver::Error::StaleElementReferenceError,
307
- Selenium::WebDriver::Error::ElementClickInterceptedError,
308
- Selenium::WebDriver::Error::ElementNotInteractableError].freeze
309
- HELPER_FILES = [__FILE__, File.expand_path('raider_element.rb', __dir__)].freeze
310
-
311
- def initialize(driver, timeout: nil)
312
- @driver = driver
313
- @timeout = timeout
314
- @last_rect = nil
315
- end
316
-
317
- def find_element(lookup, visible)
318
- perform_action(:find_element, lookup, visible ? TARGET_CHECKS : PRESENT) { RaiderElement.new(@driver, lookup) }
319
- end
320
-
321
- def find_elements(lookup)
322
- perform_action(:find_elements, lookup.many, PRESENT) do |elements|
323
- elements.map { |found| RaiderElement.new(@driver, Lookup.new(@driver, element: found, hint: Lookup::MEMBER_HINT)) }
324
- end
325
- rescue TimeoutError
326
- []
327
- end
328
-
329
- # checks: a subset of %i[visible enabled stable]; present is always checked.
330
- # target: a second Lookup (the drop target) that must be visible in the same poll.
331
- # between: called after each poll that was not ready (scroll_to scrolls there); once it
332
- # returns false the wait stops, and the timeout message ends with its to_s.
333
- # args: what the action acts with (send_keys' keys, select_by's how and what), for the observer.
334
- def perform_action(action, locator, checks, target: nil, between: nil, args: nil, &block)
335
- lookup = locator.is_a?(Lookup) ? locator : Lookup.for(@driver, locator, {})
336
- return poll(action, lookup, checks, target, between, nil, &block) unless WaitHelper.observer
337
-
338
- observed(action, lookup, args) { |run| poll(action, lookup, checks, target, between, run, &block) }
339
- end
340
-
341
- # Retries the block until it passes. Calls inside it share its deadline (see deadline_and_wait).
342
- def eventually(&block)
343
- return retry_block(nil, &block) unless WaitHelper.observer
344
-
345
- token = WaitHelper.report(:group_started) { { action: :eventually, source: Observation.source } }
346
- run = { retries: 0, token: token }
347
- begin
348
- result = retry_block(run, &block)
349
- WaitHelper.report(:group_finished, token, status: :passed, retries: run[:retries], error: nil) if token
350
- result
351
- rescue Exception => e # rubocop:disable Lint/RescueException
352
- WaitHelper.report(:group_finished, token, status: :failed, retries: run[:retries], error: e) if token
353
- raise
354
- end
355
- end
356
-
357
- private
358
-
359
- # Tells the observer an action started, when its element is ready and how it ended. The
360
- # block gets a run hash that poll fills with the retries it took and the ready element.
361
- def observed(action, lookup, args)
362
- run = { retries: 0, element: nil, token: nil }
363
- token = run[:token] = WaitHelper.report(:action_started) do
364
- { action: action, locator: lookup.trace_locator, within: lookup.trace_within,
365
- source: Observation.source, args: args }
366
- end
367
- started = now
368
- begin
369
- result = yield(run)
370
- finished(token, :passed, started, run, nil)
371
- result
372
- rescue Exception => e # rubocop:disable Lint/RescueException
373
- finished(token, :failed, started, run, e)
374
- raise
375
- end
376
- end
377
-
378
- def finished(token, status, started, run, error)
379
- return unless token
380
-
381
- WaitHelper.report(:action_finished, token, status: status, waited_ms: ((now - started) * 1000).round,
382
- retries: run[:retries], error: error, element: run[:element])
383
- end
384
-
385
- # run: where to count retries and keep the ready element, or nil when nobody observes.
386
- def poll(action, lookup, checks, target, between, run)
387
- deadline, waited = deadline_and_wait
388
- loop do
389
- seen = catch(:not_ready) do
390
- elements = [ready(lookup, checks)]
391
- elements << ready(target, TARGET_CHECKS, 'drop target ') if target
392
- ready_to_act(run, elements.first) if run
393
- return yield(*elements)
394
- rescue *RETRIED => e
395
- retry_reason(lookup, e, action)
396
- end
397
- lookup.reset(stale: seen == STALE)
398
- target&.reset
399
- if now >= deadline || (between && !between.call)
400
- seen = [seen, between].compact.join(' ')
401
- raise TimeoutError, timeout_message(lookup, checks, seen, [waited, waited + now - deadline].min), backtrace
402
- end
403
-
404
- run[:retries] += 1 if run
405
- pause(deadline)
406
- end
407
- end
408
-
409
- # Keeps the ready element and tells the observer, just before the action runs on it.
410
- def ready_to_act(run, element)
411
- run[:element] = element
412
- token = run[:token]
413
- WaitHelper.report(:action_ready, token, element: element) if token
414
- end
415
-
416
- def retry_block(run)
417
- enclosing = Thread.current[DEADLINE_KEY]
418
- require 'rspec/expectations'
419
- retried = [RSpec::Expectations::ExpectationNotMetError, Selenium::WebDriver::Error::NoSuchElementError,
420
- Selenium::WebDriver::Error::StaleElementReferenceError, TimeoutError]
421
- deadline, waited = deadline_and_wait
422
- Thread.current[DEADLINE_KEY] = deadline
423
- loop do
424
- return yield
425
- rescue *retried => e
426
- raise e.exception("(still failing after #{seconds(waited)}s)\n#{e.message}") if now >= deadline
427
-
428
- retried(run, e)
429
- pause(deadline)
430
- end
431
- ensure
432
- Thread.current[DEADLINE_KEY] = enclosing
433
- end
434
-
435
- # Counts a failed pass of eventually, and tells the observer a new pass starts.
436
- def retried(run, error)
437
- return unless run
438
-
439
- run[:retries] += 1
440
- token = run[:token]
441
- WaitHelper.report(:group_retried, token, error: error) if token
442
- end
443
-
444
- # Inside eventually, a call without timeout: waits for what is left of eventually's
445
- # deadline; one with timeout: waits that long per attempt, never past eventually's deadline.
446
- def deadline_and_wait
447
- started = now
448
- enclosing = Thread.current[DEADLINE_KEY]
449
- own = started + (@timeout || WaitHelper.default_timeout) if @timeout || !enclosing
450
- deadline = [own, enclosing].compact.min
451
- [deadline, deadline - started]
452
- end
453
-
454
- def ready(lookup, checks, prefix = '')
455
- element = lookup.locate
456
- reason = unready(element, checks)
457
- throw :not_ready, "#{prefix}#{reason}" if reason
458
-
459
- element
460
- rescue Selenium::WebDriver::Error::NoSuchElementError
461
- throw :not_ready, "#{prefix}not found"
462
- end
463
-
464
- def unready(element, checks)
465
- return 'present but not visible' if checks.include?(:visible) && !element.displayed?
466
- return 'visible but disabled' if checks.include?(:enabled) && !element.enabled?
467
-
468
- 'still moving' if checks.include?(:stable) && !stable?(element)
469
- end
470
-
471
- # Stable: the same rect on two consecutive polls.
472
- def stable?(element)
473
- rect = element.rect
474
- steady = rect == @last_rect
475
- @last_rect = rect
476
- steady
477
- end
478
-
479
- def retry_reason(lookup, error, action)
480
- detail = error.message.lines.first.to_s.strip
481
- case error
482
- when Selenium::WebDriver::Error::StaleElementReferenceError
483
- lost = lookup.lost
484
- raise error.class, "#{detail} (during #{action}): #{lost.hint}", backtrace if lost
485
-
486
- STALE
487
- when Selenium::WebDriver::Error::ElementClickInterceptedError then "click intercepted: #{detail}"
488
- else "not interactable: #{detail}"
489
- end
490
- end
491
-
492
- def timeout_message(lookup, checks, seen, waited)
493
- names = checks.map(&:to_s)
494
- wanted = names.empty? ? 'present' : [names[0..-2].join(', '), names.last].reject(&:empty?).join(' and ')
495
- "Waited #{seconds(waited)}s for #{lookup.describe} to be #{wanted}; last seen: #{seen}"
496
- end
497
-
498
- def seconds(value)
499
- rounded = value.round(2)
500
- rounded == rounded.to_i ? rounded.to_i : rounded
501
- end
502
-
503
- # Drops this helper's frames so the error points at the page object or spec line.
504
- def backtrace
505
- caller.reject { |line| HELPER_FILES.any? { |file| line.start_with?(file) } }
506
- .drop_while { |line| line.start_with?('<internal:') }
507
- end
508
-
509
- def pause(deadline)
510
- sleep([[POLL_INTERVAL, deadline - now].min, 0].max)
511
- end
512
-
513
- def now
514
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
515
- end
516
- end
517
-
518
- # Where an action was called from, for the observer.
519
- module Observation
520
- # helpers/ by its given and its real path (__dir__ is the real one): the wait, trace and
521
- # driver helpers are never the line that called an action or a navigation.
522
- HELPER_DIRS = [File.dirname(__FILE__), __dir__].uniq.map { |dir| File.join(dir, '') }.freeze
523
-
524
- # The first frame outside helpers/, Ruby and gems, as { file:, line: } (file relative
525
- # to the working directory when inside it), or nil.
526
- def self.source
527
- frame = caller_locations.find { |location| project?(location.absolute_path || location.path) }
528
- return unless frame
529
-
530
- { file: relative(frame.absolute_path || frame.path), line: frame.lineno }
531
- end
532
-
533
- def self.project?(path)
534
- return false if path.start_with?('<internal:') || HELPER_DIRS.any? { |dir| path.start_with?(dir) }
535
-
536
- library_paths.none? { |root| path.start_with?(root) }
537
- end
538
-
539
- def self.library_paths
540
- @library_paths ||= [*RbConfig::CONFIG.values_at('rubylibdir', 'rubyarchdir'), *Gem.path]
541
- .compact.map { |dir| File.join(dir, '') }
542
- end
543
-
544
- def self.relative(path)
545
- root = File.join(Dir.pwd, '')
546
- path.start_with?(root) ? path.delete_prefix(root) : path
547
- end
548
- end
549
- <%- if mobile? -%>
550
-
551
- # A swipe or scroll through the platform's own mobile: command, as appium_lib issues them.
552
- class Gesture
553
- DIRECTIONS = %w[up down left right].freeze
554
- COMMANDS = {
555
- ios: { swipe: 'mobile: swipe', scroll: 'mobile: scroll' }.freeze,
556
- android: { swipe: 'mobile: swipeGesture', scroll: 'mobile: scrollGesture' }.freeze
557
- }.freeze
558
- PERCENT = 0.75
559
-
560
- def initialize(driver, kind, direction)
561
- @driver = driver
562
- @device = driver.appium_device.to_s.downcase.to_sym
563
- @command = COMMANDS.dig(@device, kind)
564
- raise NotImplementedError, "#{kind} has no gesture for #{driver.appium_device.inspect}; only :ios and :android" unless @command
565
-
566
- @direction = direction.to_s
567
- raise ArgumentError, "direction must be up, down, left or right; got #{direction.inspect}" unless DIRECTIONS.include?(@direction)
568
- end
569
-
570
- # element: the raw element to act on, or nil for the whole screen.
571
- def perform(element)
572
- @driver.execute_script(@command, arguments(element))
573
- end
574
-
575
- private
576
-
577
- def arguments(element)
578
- return { direction: @direction, element: element&.id }.compact if @device == :ios
579
-
580
- area = element ? { elementId: element.id } : screen
581
- area.merge(direction: @direction, percent: PERCENT)
582
- end
583
-
584
- def screen
585
- rect = @driver.window_rect
586
- { left: rect.x, top: rect.y, width: rect.width, height: rect.height }
587
- end
588
- end
589
-
590
- # Scrolls the screen once per call, between scroll_to's polls, up to max_scrolls times.
591
- class Scroller
592
- def initialize(gesture, max_scrolls)
593
- @gesture = gesture
594
- @max_scrolls = max_scrolls
595
- @scrolls = 0
596
- end
597
-
598
- # False once every scroll is used up.
599
- def call
600
- return false if @scrolls >= @max_scrolls
601
-
602
- @gesture.perform(nil)
603
- @scrolls += 1
604
- end
605
-
606
- def to_s
607
- "after #{@scrolls} scrolls"
608
- end
609
- end
610
- <%- end -%>
611
- end
@@ -1,6 +0,0 @@
1
- Feature: <%= class_name %>
2
-
3
- Scenario: Scenario name
4
- Given I am on the <%= normalized_name.tr('_', ' ') %> page
5
- When I perform an action
6
- Then I see the expected result
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative '../../page_objects/pages/<%= normalized_name %>'
4
-
5
- Given('I am on the <%= normalized_name.tr("_", " ") %> page') do
6
- <%- if selenium? -%>
7
- @page = <%= page_class_name %>.new(driver)
8
- <%- elsif watir? -%>
9
- @page = <%= page_class_name %>.new(browser)
10
- <%- end -%>
11
- end
12
-
13
- When('I perform an action') do
14
- pending 'implement step'
15
- end
16
-
17
- Then('I see the expected result') do
18
- pending 'implement step'
19
- end
@@ -1,12 +0,0 @@
1
- # Type definitions for CucumberGenerator
2
- class CucumberGenerator < Generator
3
- def generate_feature: () -> void
4
- def generate_steps: () -> void
5
- def generate_user_factory: () -> void
6
- def generate_env_file: () -> void
7
- def generate_world: () -> void
8
- def generate_cucumber_file: () -> void
9
- def generate_accessibility_feature: () -> void
10
- def generate_accessibility_steps: () -> void
11
- def template_name: () -> String
12
- end