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,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'selenium-webdriver'
4
+ require_relative 'config'
5
+ require_relative 'errors'
6
+ require_relative 'wait'
7
+ require_relative 'context'
8
+
9
+ module Raider
10
+ # Waits for an element to be actionable before an action runs. On web contexts
11
+ # the checks run in the browser (js/actionability.js); on native ones the gate
12
+ # polls displayed?/enabled?. This module owns the check tables and the poll loops.
13
+ # :reek:TooManyConstants { max_constants: 6 } -- one table per check set, the script and the notice
14
+ module Actionability
15
+ CHECKS = {
16
+ click: %w[visible enabled inert stable events],
17
+ send_keys: %w[visible enabled editable inert],
18
+ clear: %w[visible enabled editable inert],
19
+ submit: %w[visible enabled inert]
20
+ }.freeze
21
+
22
+ NATIVE_CHECKS = {
23
+ click: %w[visible enabled],
24
+ send_keys: %w[visible enabled],
25
+ clear: %w[visible enabled],
26
+ submit: []
27
+ }.freeze
28
+
29
+ PHRASES = {
30
+ 'visible' => 'be visible',
31
+ 'enabled' => 'be enabled',
32
+ 'editable' => 'be editable',
33
+ 'inert' => 'not be inert',
34
+ 'stable' => 'be stable',
35
+ 'events' => 'receive events'
36
+ }.freeze
37
+
38
+ SCRIPT = File.read(File.join(__dir__, 'js', 'actionability.js')).freeze
39
+ FIRST_POLL = { 'check' => 'stable', 'first' => true }.freeze
40
+ NOTICE = '[raider] actionability on (Raider.actionability = false or force: true to skip)'
41
+
42
+ class << self
43
+ # Returns the number of not-ready polls (0 when skipped or ready at once).
44
+ def ensure(element, action, force:)
45
+ return 0 unless Raider.actionability
46
+
47
+ announce
48
+ web = Context.web_for?(element)
49
+ return attached(element, web) if force
50
+
51
+ poll(element) { web ? polled(element, action) : native_failure(element, action) }
52
+ end
53
+
54
+ def check(element, action)
55
+ element.instance_variable_get(:@bridge).execute_script(SCRIPT, element, CHECKS.fetch(action))
56
+ end
57
+
58
+ def reset_notice
59
+ @announced = false
60
+ end
61
+
62
+ private
63
+
64
+ # force skips every check but attached: a stale element raises here.
65
+ def attached(element, web)
66
+ web ? element.tag_name : element.displayed?
67
+ 0
68
+ end
69
+
70
+ # Counts the polls whose block returns a failing check; the last one names the timeout.
71
+ def poll(element)
72
+ last = nil
73
+ retries = 0
74
+ started = clock
75
+ Wait.until(timeout: Raider.timeout) do
76
+ last = yield
77
+ next true unless last
78
+
79
+ retries += 1
80
+ throw :not_ready, last['check']
81
+ end
82
+ retries
83
+ rescue TimeoutError
84
+ raise not_actionable(element, last, clock - started, retries)
85
+ end
86
+
87
+ # Plain Appium commands, no script: the first check that fails, else nil.
88
+ def native_failure(element, action)
89
+ failing = NATIVE_CHECKS.fetch(action).find { |name| !native_ready?(element, name) }
90
+ failing && { 'check' => failing }
91
+ end
92
+
93
+ # The first poll only stores a rect for stable to compare with, so a short pause
94
+ # (inside the block; Wait.until's interval is fixed) replaces the full poll interval.
95
+ def polled(element, action)
96
+ result = check(element, action)
97
+ return result unless result == FIRST_POLL
98
+
99
+ sleep 0.02
100
+ check(element, action)
101
+ end
102
+
103
+ def native_ready?(element, name)
104
+ name == 'visible' ? element.displayed? : element.enabled?
105
+ end
106
+
107
+ def clock
108
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
109
+ end
110
+
111
+ def not_actionable(element, last, waited, retries)
112
+ check = last.fetch('check')
113
+ NotActionable.new(Describe.element(element), PHRASES.fetch(check, check), covering: covering(last), waited: waited, retries: retries)
114
+ end
115
+
116
+ def covering(last)
117
+ found = last['covering']
118
+ found&.transform_keys(&:to_sym)
119
+ end
120
+
121
+ def announce
122
+ return if @announced || ENV['RAIDER_QUIET'] == '1'
123
+
124
+ @announced = true
125
+ $stderr.puts NOTICE # rubocop:disable Style/StderrPuts -- the notice must not be silenced by -W0
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'selenium-webdriver'
4
+ require_relative 'observer'
5
+ require_relative 'actionability'
6
+
7
+ module Raider
8
+ # Waits for actionability before each Element action. force: true skips the
9
+ # checks except that the element must still be attached.
10
+ module Actionable
11
+ def click(force: false)
12
+ Observer.emit_action(self, :click) do |run|
13
+ raider_gate(run, :click, force)
14
+ super()
15
+ end
16
+ end
17
+
18
+ def send_keys(*keys, force: false)
19
+ Observer.emit_action(self, :send_keys, keys) do |run|
20
+ raider_gate(run, :send_keys, force)
21
+ super(*keys)
22
+ end
23
+ end
24
+ alias send_key send_keys
25
+
26
+ def clear(force: false)
27
+ Observer.emit_action(self, :clear) do |run|
28
+ raider_gate(run, :clear, force)
29
+ super()
30
+ end
31
+ end
32
+
33
+ def submit(force: false)
34
+ Observer.emit_action(self, :submit) do |run|
35
+ raider_gate(run, :submit, force)
36
+ super()
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def raider_gate(run, action, force)
43
+ retries = Actionability.ensure(self, action, force: force)
44
+ run&.ready(retries)
45
+ end
46
+ end
47
+ end
48
+
49
+ Selenium::WebDriver::Element.prepend(Raider::Actionable)
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raider
4
+ # The WAI-ARIA 1.2 concrete roles, plus generic.
5
+ ROLES = %w[
6
+ alert alertdialog application article banner blockquote button caption cell checkbox code columnheader
7
+ combobox complementary contentinfo definition deletion dialog directory document emphasis feed figure form
8
+ generic grid gridcell group heading img insertion link list listbox listitem log main marquee math menu
9
+ menubar menuitem menuitemcheckbox menuitemradio meter navigation none note option paragraph presentation
10
+ progressbar radio radiogroup region row rowgroup rowheader scrollbar search searchbox separator slider
11
+ spinbutton status strong subscript superscript switch tab table tablist tabpanel term textbox time timer
12
+ toolbar tooltip tree treegrid treeitem
13
+ ].freeze
14
+
15
+ # Builds the role locator: a Hash the finders turn into a raider role query.
16
+ module RoleLocator
17
+ module_function
18
+
19
+ def build(role, name, exact, include_hidden, level = nil)
20
+ token = validated_role(role)
21
+ raise ArgumentError, "name must be a String, got #{name.class}" unless name.nil? || name.is_a?(String)
22
+
23
+ validated_level(token, level)
24
+ options = { name: name, exact: (true if exact), include_hidden: (true if include_hidden), level: level }.compact
25
+ { role: options.empty? ? [token] : [token, options] }
26
+ end
27
+
28
+ # level: filters headings by h1-h6 or aria-level; nil means any level.
29
+ def validated_level(token, level)
30
+ return if level.nil?
31
+ raise ArgumentError, "level: only applies to the heading role, not '#{token}'" unless token == 'heading'
32
+ return if level.is_a?(Integer) && level.between?(1, 6)
33
+
34
+ raise ArgumentError, "level: must be an Integer from 1 to 6, got #{level.inspect}"
35
+ end
36
+
37
+ def validated_role(role)
38
+ token = role.to_s
39
+ return token if ROLES.include?(token)
40
+
41
+ raise ArgumentError, "unknown role '#{token}'; roles are the ARIA role names such as button, link, textbox"
42
+ end
43
+ end
44
+
45
+ def self.by_role(role, name: nil, exact: false, include_hidden: false, level: nil)
46
+ RoleLocator.build(role, name, exact, include_hidden, level)
47
+ end
48
+
49
+ # Locator helpers for page objects and wait helpers.
50
+ module Helpers
51
+ def by_role(role, **options)
52
+ Raider.by_role(role, **options)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ # Runtime configuration. Precedence per key: explicit setter, then env,
6
+ # then config/config.yml, then the default.
7
+ module Raider
8
+ CONFIG_FILE = 'config/config.yml'
9
+ FALSY = %w[off false 0 no].freeze
10
+
11
+ # Load-order and configuration mistakes.
12
+ class Error < StandardError; end
13
+
14
+ class << self
15
+ def strict
16
+ resolve(:strict, 'RAIDER_STRICT', true) { |value| truthy?(value) }
17
+ end
18
+
19
+ def actionability
20
+ resolve(:actionability, 'RAIDER_ACTIONABILITY', true) { |value| truthy?(value) }
21
+ end
22
+
23
+ def timeout
24
+ resolve(:timeout, 'RAIDER_TIMEOUT', 10) { |value| number(value) }
25
+ end
26
+
27
+ def test_id_attribute
28
+ resolve(:test_id_attribute, 'RAIDER_TEST_ID_ATTRIBUTE', 'data-testid', &:to_s)
29
+ end
30
+
31
+ attr_writer :strict, :actionability, :timeout, :test_id_attribute
32
+
33
+ # Any object (or nil) receiving action events; see Raider::Observer.
34
+ attr_accessor :observer
35
+
36
+ def reset
37
+ @strict = @actionability = @timeout = @test_id_attribute = nil
38
+ @observer = nil
39
+ @file_config = nil
40
+ end
41
+
42
+ private
43
+
44
+ def resolve(key, env_name, default)
45
+ explicit = instance_variable_get("@#{key}")
46
+ return explicit unless explicit.nil?
47
+
48
+ raw = ENV.fetch(env_name, nil)
49
+ raw = file_config[key.to_s] if raw.nil? || raw.empty?
50
+ raw.nil? ? default : yield(raw)
51
+ end
52
+
53
+ def file_config
54
+ @file_config ||= load_file
55
+ end
56
+
57
+ def load_file
58
+ return {} unless File.exist?(CONFIG_FILE)
59
+
60
+ data = YAML.safe_load_file(CONFIG_FILE, permitted_classes: [Symbol])
61
+ data.is_a?(Hash) ? data : {}
62
+ rescue Psych::Exception => e
63
+ raise Error, "cannot read #{CONFIG_FILE}: #{e.message}"
64
+ end
65
+
66
+ def truthy?(value)
67
+ return value if [true, false].include?(value)
68
+
69
+ !FALSY.include?(value.to_s.strip.downcase)
70
+ end
71
+
72
+ def number(value)
73
+ return value if value.is_a?(Numeric)
74
+
75
+ float = Float(value)
76
+ float == float.to_i ? float.to_i : float
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'selenium-webdriver'
4
+
5
+ module Raider
6
+ # Decides whether strictness and actionability apply: on for Selenium drivers
7
+ # and Appium webviews, off on an Appium NATIVE_APP context.
8
+ module Context
9
+ NATIVE = 'NATIVE_APP'
10
+
11
+ class << self
12
+ def web?(bridge)
13
+ return true unless contextual?(bridge)
14
+
15
+ bridge.current_context != NATIVE
16
+ end
17
+
18
+ def web_for?(search_context)
19
+ bridge = search_context.instance_variable_get(:@bridge)
20
+ bridge.nil? || web?(bridge)
21
+ end
22
+
23
+ private
24
+
25
+ # Appium bridges answer current_context; an event-firing wrapper delegates it.
26
+ def contextual?(bridge)
27
+ # selenium 4.30's event-firing wrapper lacks respond_to_missing?, so look at its delegate.
28
+ bridge = bridge.instance_variable_get(:@delegate) if event_firing?(bridge)
29
+ bridge.respond_to?(:current_context)
30
+ end
31
+
32
+ def event_firing?(bridge)
33
+ defined?(Selenium::WebDriver::Support::EventFiringBridge) &&
34
+ bridge.is_a?(Selenium::WebDriver::Support::EventFiringBridge)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'selenium-webdriver'
4
+ require_relative 'config'
5
+
6
+ module Raider
7
+ # Formats elements and durations for error messages.
8
+ module Describe
9
+ TEXT_LIMIT = 30
10
+ CLASS_LIMIT = 2
11
+
12
+ module_function
13
+
14
+ def seconds(value)
15
+ rounded = value.round(2)
16
+ rounded == rounded.to_i ? rounded.to_i : rounded
17
+ end
18
+
19
+ def element(element)
20
+ tag = element.tag_name
21
+ id = element.attribute('id').to_s
22
+ classes = element.attribute('class').to_s.split.first(CLASS_LIMIT).join(' ')
23
+ text = element.text.to_s.strip[0, TEXT_LIMIT]
24
+ open = "<#{tag}#{attribute_part('id', id)}#{attribute_part('class', classes)}>"
25
+ text.empty? ? open : "#{open}#{text}</#{tag}>"
26
+ rescue Selenium::WebDriver::Error::WebDriverError
27
+ '<element>'
28
+ end
29
+
30
+ def attribute_part(name, value)
31
+ value.empty? ? '' : %( #{name}="#{value}")
32
+ end
33
+ end
34
+
35
+ # A strict finder matched more than one element.
36
+ class AmbiguousMatch < Selenium::WebDriver::Error::WebDriverError
37
+ SHOWN = 3
38
+
39
+ attr_reader :elements
40
+
41
+ def initialize(locator_description, elements)
42
+ @elements = elements
43
+ listed = elements.first(SHOWN).map { |element| "`#{Describe.element(element)}`" }.join(', ')
44
+ more = [elements.size - SHOWN, 0].max
45
+ super("`#{locator_description}` matched #{elements.size} elements: #{listed} (+#{more} more); " \
46
+ 'use find_elements, or narrow the locator')
47
+ end
48
+ end
49
+
50
+ # An element did not become actionable within the timeout.
51
+ class NotActionable < Selenium::WebDriver::Error::ElementNotInteractableError
52
+ attr_reader :check, :covering, :retries
53
+
54
+ def initialize(element_description, check, covering: nil, waited: Raider.timeout, retries: 0)
55
+ @check = check
56
+ @retries = retries
57
+ @covering = covering
58
+ message = "waited #{Describe.seconds(waited)}s for `#{element_description}` to #{check}"
59
+ message += "; `#{covering[:element]}` covers it at (#{covering[:x]}, #{covering[:y]})" if covering
60
+ super(message)
61
+ end
62
+ end
63
+
64
+ # The requested operation is not supported.
65
+ class NotSupported < Selenium::WebDriver::Error::UnsupportedOperationError; end
66
+
67
+ # A runtime wait timed out.
68
+ class TimeoutError < Selenium::WebDriver::Error::TimeoutError; end
69
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'selenium-webdriver'
4
+ require_relative 'errors'
5
+ require_relative 'locator_converter'
6
+
7
+ module Raider
8
+ FINDERS = {
9
+ label: 'raider label',
10
+ placeholder: 'raider placeholder',
11
+ alt: 'raider alt',
12
+ title: 'raider title',
13
+ text: 'raider text',
14
+ test_id: 'raider test id',
15
+ role: 'raider role'
16
+ }.freeze
17
+
18
+ # Registers the Raider locator keys and converter on Selenium and Appium.
19
+ module Finders
20
+ BRIDGE = 'Selenium::WebDriver::Remote::Bridge'
21
+ SEARCH_CONTEXT = 'Selenium::WebDriver::SearchContext'
22
+ SELENIUM_BRIDGES = %w[Selenium::WebDriver::Remote::Bridge Selenium::WebDriver::Remote::BiDiBridge].freeze
23
+ APPIUM_BRIDGES = %w[Appium::Core::Base::Bridge Appium::Core::Base::BiDiBridge].freeze
24
+
25
+ module_function
26
+
27
+ def register
28
+ context = Selenium::WebDriver::SearchContext
29
+ context.extra_finders = (context.extra_finders || {}).merge(FINDERS)
30
+ end
31
+
32
+ # Covers a late `require 'appium_lib'`, which overwrites the finder table
33
+ # and assigns its own bridge converter. Bridges created before the wrap keep
34
+ # the converter they copied; require the runtime before creating a driver.
35
+ def ensure_registered
36
+ register unless Selenium::WebDriver::SearchContext.finders.key?(:label)
37
+ install_appium_converters
38
+ end
39
+
40
+ def install_converter(bridge_class)
41
+ return if bridge_class.locator_converter.is_a?(LocatorConverter)
42
+
43
+ bridge_class.locator_converter = LocatorConverter.new(bridge_class.locator_converter)
44
+ end
45
+
46
+ def install
47
+ register
48
+ SELENIUM_BRIDGES.each { |name| install_converter(Object.const_get(name)) }
49
+ install_appium_converters
50
+ end
51
+
52
+ # Each bridge class keeps its own converter, so the BiDi subclasses need
53
+ # their own wrap. Appium classes are only touched when Appium is loaded.
54
+ def install_appium_converters
55
+ APPIUM_BRIDGES.each do |name|
56
+ install_converter(Object.const_get(name)) if Object.const_defined?(name)
57
+ end
58
+ end
59
+
60
+ def check_api
61
+ bridge = Selenium::WebDriver::Remote::Bridge
62
+ context = Selenium::WebDriver::SearchContext
63
+ raise Error, "#{BRIDGE} must respond to locator_converter=" unless bridge.respond_to?(:locator_converter=)
64
+ raise Error, "#{SEARCH_CONTEXT} must respond to extra_finders=" unless context.respond_to?(:extra_finders=)
65
+ end
66
+
67
+ def guard
68
+ return unless ObjectSpace.each_object(Selenium::WebDriver::Remote::Bridge).any?
69
+
70
+ raise Error, "require 'ruby_raider/runtime' before creating the driver"
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,134 @@
1
+ // Actionability checks for Raider::Actionability. Runs as an execute_script body:
2
+ // arguments[0] is the element, arguments[1] the Array of check names to run.
3
+ // Returns null when every check passes, else { check, covering? } for the first failure.
4
+ // Read-only apart from scrolling: it never sets styles, focuses, or dispatches events.
5
+ const el = arguments[0];
6
+ const names = arguments[1];
7
+ const isOption = el.tagName === 'OPTION';
8
+ const typing = names.includes('editable');
9
+
10
+ const describe = (node) => {
11
+ let text = node.tagName.toLowerCase();
12
+ if (node.id) text += '#' + node.id;
13
+ const classes = Array.from(node.classList || []).slice(0, 2);
14
+ classes.forEach((name) => { text += '.' + name; });
15
+ return text;
16
+ };
17
+
18
+ // One step up the composed tree: the parent, or the host when leaving a shadow root.
19
+ const up = (node) => node.parentNode || node.host || null;
20
+
21
+ // First element on the composed path from node to the root (node included) that passes test.
22
+ const composedClosest = (node, test) => {
23
+ for (let current = node; current; current = up(current)) {
24
+ if (current.nodeType === 1 && test(current)) return current;
25
+ }
26
+ return null;
27
+ };
28
+
29
+ const composedContains = (ancestor, node) => composedClosest(node, (current) => current === ancestor) !== null;
30
+
31
+ // Not display: none on the node or any ancestor (a file input may have an empty rect).
32
+ const displayed = (node) => {
33
+ if (node.checkVisibility) return node.checkVisibility();
34
+ return !composedClosest(node, (current) => window.getComputedStyle(current).display === 'none');
35
+ };
36
+
37
+ const visible = () => {
38
+ const target = isOption ? (el.closest('select') || el) : el;
39
+ const style = window.getComputedStyle(target);
40
+ if (target.tagName === 'INPUT' && target.type === 'file') {
41
+ // File inputs are routinely hidden behind a styled label, so typing a path skips visible.
42
+ return typing || displayed(target);
43
+ }
44
+ const rect = target.getBoundingClientRect();
45
+ if (!(rect.width > 0 && rect.height > 0)) return false;
46
+ if (target.checkVisibility) return target.checkVisibility({ visibilityProperty: true });
47
+ return style.visibility !== 'hidden';
48
+ };
49
+
50
+ const enabled = () => {
51
+ if (el.disabled) return false;
52
+ const fieldset = composedClosest(el, (node) => node.tagName === 'FIELDSET' && node.hasAttribute('disabled'));
53
+ if (!fieldset) return true;
54
+ const legend = fieldset.querySelector(':scope > legend');
55
+ return !!legend && composedContains(legend, el);
56
+ };
57
+
58
+ // Keys can go to any element (body for Escape, a button for Enter); only a readonly field refuses them.
59
+ const editable = () => !((el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') && el.readOnly);
60
+
61
+ const modalDialog = () => {
62
+ try {
63
+ const modal = document.querySelector('dialog:modal');
64
+ if (modal) return modal;
65
+ } catch (error) {
66
+ // :modal is not supported; fall through to the open-dialog scan.
67
+ }
68
+ return Array.from(document.querySelectorAll('dialog[open]')).find((dialog) => {
69
+ try { return dialog.matches(':modal'); } catch (error) { return false; }
70
+ }) || null;
71
+ };
72
+
73
+ const notInert = () => {
74
+ if (composedClosest(el, (node) => node.hasAttribute('inert'))) return false;
75
+ const dialog = modalDialog();
76
+ return !dialog || composedContains(dialog, el);
77
+ };
78
+
79
+ const stable = () => {
80
+ if (isOption) return true;
81
+ window.__raiderRects = window.__raiderRects || new WeakMap();
82
+ const box = el.getBoundingClientRect();
83
+ const now = [box.x + window.scrollX, box.y + window.scrollY, box.width, box.height].map(Math.round).join(',');
84
+ const before = window.__raiderRects.get(el);
85
+ window.__raiderRects.set(el, now);
86
+ return before === undefined ? 'first' : before === now;
87
+ };
88
+
89
+ const clamp = (value, max) => Math.max(0, Math.min(Math.round(value), Math.max(max - 1, 0)));
90
+
91
+ // Returns null when the centre point reaches the element, else a failure with covering.
92
+ const events = () => {
93
+ if (isOption) return null;
94
+ const box = el.getBoundingClientRect();
95
+ const x = clamp(box.x + box.width / 2, window.innerWidth);
96
+ const y = clamp(box.y + box.height / 2, window.innerHeight);
97
+ const root = el.getRootNode();
98
+ const hit = (root.elementFromPoint ? root : document).elementFromPoint(x, y);
99
+ if (hit && el.contains(hit)) return null;
100
+ return hit ? { check: 'events', covering: { element: describe(hit), x: x, y: y } } : { check: 'events' };
101
+ };
102
+
103
+ const within = (box, outer) => (
104
+ box.top >= outer.top && box.left >= outer.left && box.bottom <= outer.bottom && box.right <= outer.right
105
+ );
106
+
107
+ // Inside the viewport (without scrollbars) and inside every clipping ancestor (overflow not visible).
108
+ const unclipped = () => {
109
+ const box = el.getBoundingClientRect();
110
+ const root = document.documentElement;
111
+ if (!within(box, { top: 0, left: 0, bottom: root.clientHeight, right: root.clientWidth })) return false;
112
+ return !composedClosest(up(el) || el, (node) => {
113
+ if (node === root || node === document.body) return false;
114
+ const style = window.getComputedStyle(node);
115
+ if (style.overflowX === 'visible' && style.overflowY === 'visible') return false;
116
+ return !within(box, node.getBoundingClientRect());
117
+ });
118
+ };
119
+
120
+ if (!isOption && !unclipped()) el.scrollIntoView({ block: 'center', inline: 'center' });
121
+
122
+ const simple = { visible: visible, enabled: enabled, editable: editable, inert: notInert, stable: stable };
123
+
124
+ for (const name of names) {
125
+ if (name === 'events') {
126
+ const failure = events();
127
+ if (failure) return failure;
128
+ } else {
129
+ const result = simple[name]();
130
+ if (result === 'first') return { check: name, first: true };
131
+ if (!result) return { check: name };
132
+ }
133
+ }
134
+ return null;