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,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Raider
6
+ module Matchers
7
+ # Matchers that read one element. Readers go through read_with_zero_wait so a
8
+ # template element (which takes timeout:) never waits inside the matcher's own wait.
9
+ module Element
10
+ # Collapses runs of whitespace so text compares the way it reads on the page.
11
+ def self.normalise(text)
12
+ text.to_s.strip.gsub(/\s+/, ' ')
13
+ end
14
+
15
+ # The text of an element, as a substring or (exact: true) as equality.
16
+ class HaveText < Base
17
+ def initialize(text, exact: false, wait: nil)
18
+ super(text, wait: wait, exact: exact)
19
+ end
20
+
21
+ def verb
22
+ option(:exact) ? 'have exact text' : 'have text'
23
+ end
24
+
25
+ def read(subject)
26
+ read_with_zero_wait(subject, :text)
27
+ end
28
+
29
+ def pass?(value)
30
+ actual = Element.normalise(value)
31
+ return expected.match?(actual) if expected.is_a?(Regexp)
32
+
33
+ wanted = Element.normalise(expected)
34
+ option(:exact) ? actual == wanted : actual.include?(wanted)
35
+ end
36
+ end
37
+
38
+ # The `value` attribute of a field.
39
+ class HaveValue < Base
40
+ def verb
41
+ 'have value'
42
+ end
43
+
44
+ def read(subject)
45
+ read_with_zero_wait(subject, :attribute, 'value')
46
+ end
47
+
48
+ def pass?(value)
49
+ expected.is_a?(Regexp) ? expected.match?(value.to_s) : value.to_s == expected.to_s
50
+ end
51
+ end
52
+
53
+ # An attribute that is present, equal to a String or matching a Regexp.
54
+ class HaveAttribute < Base
55
+ PRESENT = :present
56
+
57
+ def initialize(name, value = PRESENT, wait: nil)
58
+ super(name, wait: wait, value: value)
59
+ end
60
+
61
+ def verb
62
+ 'have attribute'
63
+ end
64
+
65
+ def description
66
+ wanted = option(:value)
67
+ wanted == PRESENT ? super : "#{verb} #{expected.inspect} #{wanted.inspect}"
68
+ end
69
+
70
+ def read(subject)
71
+ read_with_zero_wait(subject, :attribute, expected)
72
+ end
73
+
74
+ def pass?(value)
75
+ wanted = option(:value)
76
+ return !value.nil? if wanted == PRESENT
77
+ return false if value.nil?
78
+
79
+ wanted.is_a?(Regexp) ? wanted.match?(value.to_s) : value.to_s == wanted.to_s
80
+ end
81
+ end
82
+
83
+ # A boolean state of an element, worded by the two labels it can read as.
84
+ class State < Base
85
+ LABELS = %w[yes no].freeze
86
+
87
+ def initialize(wait: nil)
88
+ super(true, wait: wait)
89
+ end
90
+
91
+ def description
92
+ verb
93
+ end
94
+
95
+ def pass?(value)
96
+ value == true
97
+ end
98
+
99
+ private
100
+
101
+ def show(value)
102
+ value ? self.class::LABELS.first : self.class::LABELS.last
103
+ end
104
+ end
105
+
106
+ # Displayed on the page.
107
+ class BeVisible < State
108
+ LABELS = %w[visible hidden].freeze
109
+
110
+ def verb
111
+ 'be visible'
112
+ end
113
+
114
+ def read(subject)
115
+ read_with_zero_wait(subject, :displayed?)
116
+ end
117
+
118
+ private
119
+
120
+ # Gone counts as not visible for the negated form.
121
+ def negated_absent_passes?
122
+ true
123
+ end
124
+ end
125
+
126
+ # Not displayed, or gone: absence is a pass for the positive form.
127
+ class BeHidden < State
128
+ LABELS = %w[hidden visible].freeze
129
+
130
+ def verb
131
+ 'be hidden'
132
+ end
133
+
134
+ def absent_passes?
135
+ true
136
+ end
137
+
138
+ def read(subject)
139
+ !read_with_zero_wait(subject, :displayed?)
140
+ end
141
+ end
142
+
143
+ # Enabled for interaction.
144
+ class BeEnabled < State
145
+ LABELS = %w[enabled disabled].freeze
146
+
147
+ def verb
148
+ 'be enabled'
149
+ end
150
+
151
+ def read(subject)
152
+ read_with_zero_wait(subject, :enabled?)
153
+ end
154
+ end
155
+
156
+ # Not enabled.
157
+ class BeDisabled < State
158
+ LABELS = %w[disabled enabled].freeze
159
+
160
+ def verb
161
+ 'be disabled'
162
+ end
163
+
164
+ def read(subject)
165
+ !read_with_zero_wait(subject, :enabled?)
166
+ end
167
+ end
168
+
169
+ # A checked checkbox or radio, or a selected option.
170
+ class BeChecked < State
171
+ LABELS = %w[checked unchecked].freeze
172
+
173
+ def verb
174
+ 'be checked'
175
+ end
176
+
177
+ def read(subject)
178
+ read_with_zero_wait(subject, :selected?)
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Raider
6
+ module Matchers
7
+ # Matchers about the page or a collection rather than one element.
8
+ module Page
9
+ # The size of an Array (read once) or of what a block returns (re-called each poll).
10
+ class HaveCount < Base
11
+ def verb
12
+ 'have count'
13
+ end
14
+
15
+ def read(subject)
16
+ subject.size
17
+ end
18
+
19
+ def pass?(value)
20
+ value == expected
21
+ end
22
+
23
+ private
24
+
25
+ # A collection already in hand cannot change, so it gets a single pass.
26
+ def wait_for(actual)
27
+ actual.is_a?(Array) ? 0 : super
28
+ end
29
+
30
+ def target
31
+ polled_actual.is_a?(Array) ? 'the array' : super
32
+ end
33
+ end
34
+
35
+ # A value read from the driver: a String is equality, a Regexp is match?.
36
+ class Driver < Base
37
+ def pass?(value)
38
+ expected.is_a?(Regexp) ? expected.match?(value.to_s) : value.to_s == expected.to_s
39
+ end
40
+
41
+ private
42
+
43
+ def target
44
+ 'the page'
45
+ end
46
+ end
47
+
48
+ # The title of the page.
49
+ class HaveTitle < Driver
50
+ def verb
51
+ 'have title'
52
+ end
53
+
54
+ def read(subject)
55
+ subject.title
56
+ end
57
+ end
58
+
59
+ # The current url of the page, whole: 'account' does not match '.../account'.
60
+ class HaveUrl < Driver
61
+ def verb
62
+ 'have url'
63
+ end
64
+
65
+ def read(subject)
66
+ subject.current_url
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative '../strict'
5
+
6
+ module Raider
7
+ module Matchers
8
+ # Matchers about whether elements exist under a driver or element.
9
+ module Presence
10
+ # The locator matches at least one element (negated: none). `expected` is the locator.
11
+ class HaveElement < Base
12
+ def verb
13
+ 'have element'
14
+ end
15
+
16
+ # Base#settled? already reads under Wait.immediately.
17
+ def read(subject)
18
+ subject.find_elements(expected)
19
+ end
20
+
21
+ def pass?(value)
22
+ !value.empty?
23
+ end
24
+
25
+ def description
26
+ "#{verb} #{Raider::Locator.describe([expected])}"
27
+ end
28
+
29
+ private
30
+
31
+ def target
32
+ polled_actual.is_a?(Selenium::WebDriver::Driver) ? 'the page' : super
33
+ end
34
+
35
+ def show(value)
36
+ value.size
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/expectations'
4
+ require 'selenium-webdriver'
5
+ require_relative '../config'
6
+ require_relative '../errors'
7
+ require_relative '../wait'
8
+ require_relative 'base'
9
+
10
+ module Raider
11
+ module Matchers
12
+ # Prepended to RSpec's BlockExpectationTarget: `expect { value }.to eq(x)` polls the
13
+ # block until the value matches. Matchers that handle blocks themselves (Raider's,
14
+ # raise_error, change, output, yield_*) keep stock semantics and one evaluation.
15
+ # A polled block is re-run on every poll, so side effects in it repeat.
16
+ # Under aggregate_failures RSpec's notifier records the failure without raising,
17
+ # so the "(waited Ns)" prefix does not appear there.
18
+ module RetryingBlock
19
+ LOOKUP_ERRORS = [Selenium::WebDriver::Error::NoSuchElementError,
20
+ Selenium::WebDriver::Error::StaleElementReferenceError,
21
+ Raider::AmbiguousMatch,
22
+ Selenium::WebDriver::Error::TimeoutError].freeze
23
+
24
+ # One evaluation of the block and the check; remembers what it saw.
25
+ class Attempt
26
+ attr_reader :value, :error
27
+
28
+ def initialize(block, check)
29
+ @block = block
30
+ @check = check
31
+ @value = Base::NOT_FOUND
32
+ end
33
+
34
+ def read?
35
+ !@value.equal?(Base::NOT_FOUND)
36
+ end
37
+
38
+ def pass?
39
+ @error = nil
40
+ @value = Raider::Wait.immediately { @block.call }
41
+ @check.call(@value)
42
+ rescue *LOOKUP_ERRORS => e
43
+ @error = e
44
+ false
45
+ end
46
+ end
47
+
48
+ def to(matcher = nil, message = nil, &)
49
+ return super if stock?(matcher)
50
+
51
+ settle(matcher, message, :to, false, &)
52
+ end
53
+
54
+ def not_to(matcher = nil, message = nil, &)
55
+ return super if stock?(matcher)
56
+
57
+ settle(matcher, message, :not_to, true, &)
58
+ end
59
+
60
+ def to_not(...)
61
+ not_to(...)
62
+ end
63
+
64
+ private
65
+
66
+ def stock?(matcher)
67
+ matcher.nil? || matcher.is_a?(Raider::Matchers::Base) ||
68
+ (matcher.respond_to?(:supports_block_expectations?) && matcher.supports_block_expectations?)
69
+ end
70
+
71
+ def check(matcher, negated)
72
+ return matcher.method(:matches?) unless negated
73
+ return matcher.method(:does_not_match?) if matcher.respond_to?(:does_not_match?)
74
+
75
+ ->(value) { !matcher.matches?(value) }
76
+ end
77
+
78
+ def settle(matcher, message, polarity, negated, &)
79
+ attempt = Attempt.new(target, check(matcher, negated))
80
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
81
+ return true if polled?(attempt)
82
+
83
+ waited = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
84
+ give_up(attempt, [matcher, message], polarity, waited, &)
85
+ end
86
+
87
+ def polled?(attempt)
88
+ Raider::Wait.until(timeout: Raider.timeout) { attempt.pass? || throw(:not_ready) }
89
+ true
90
+ rescue Raider::TimeoutError
91
+ false
92
+ end
93
+
94
+ # The last poll's error wins; the last value goes to stock RSpec only when the last poll read one.
95
+ def give_up(attempt, arguments, polarity, waited, &)
96
+ raise(attempt.error || Raider::TimeoutError.new('the block never produced a value')) if attempt.error || !attempt.read?
97
+
98
+ RSpec::Expectations::ExpectationTarget.for(attempt.value, nil).public_send(polarity, *arguments, &)
99
+ rescue RSpec::Expectations::ExpectationNotMetError => e
100
+ raise e.exception("(waited #{Raider::Describe.seconds(waited)}s) #{e.message}"), cause: nil
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'selenium-webdriver'
4
+ require_relative 'config'
5
+ require_relative 'role_finder'
6
+
7
+ # Reports to an optional observer. A failing observer is switched off, never the test.
8
+ module Raider
9
+ class << self
10
+ # Calls hook on the observer when it answers it; the block builds the details lazily.
11
+ # A broken observer (even a NotImplementedError or SystemStackError) is switched off, never the test.
12
+ def report(hook, *args, **details)
13
+ watcher = observer
14
+ return unless watcher.respond_to?(hook)
15
+
16
+ Observer.guarded { watcher.public_send(hook, *args, **(block_given? ? yield : details)) }
17
+ rescue StandardError, ScriptError, SystemStackError => e
18
+ Observer.stop(watcher, e)
19
+ end
20
+ end
21
+
22
+ # Element actions as events, and the locator stamped on every found element.
23
+ module Observer
24
+ LIB_DIR = File.expand_path(__dir__)
25
+ GUARD_KEY = :raider_in_observer
26
+ MESSAGE_LIMIT = 200
27
+
28
+ STRATEGIES = {
29
+ 'css_selector' => 'css', 'css selector' => 'css', 'class' => 'class_name', 'class name' => 'class_name',
30
+ 'link' => 'link_text', 'link text' => 'link_text', 'partial_link' => 'partial_link_text',
31
+ 'partial link text' => 'partial_link_text', 'tag name' => 'tag_name'
32
+ }.freeze
33
+
34
+ # One reported action. :reek:TooManyInstanceVariables { max_instance_variables: 6 } -- the event's whole state
35
+ class Run
36
+ def initialize(element, action, args)
37
+ @element = element
38
+ @action = action
39
+ @args = args&.dup&.freeze # an observer can read the keys, never change what gets typed
40
+ @retries = 0
41
+ @token = Raider.report(:action_started) { Observer.started(element, action, @args) }
42
+ @started = Observer.clock
43
+ end
44
+
45
+ def ready(retries)
46
+ @retries = retries
47
+ Raider.report(:action_ready, @token) { { element: @element } } unless @token.nil?
48
+ end
49
+
50
+ def finish(error)
51
+ Raider.report(:action_finished, @token) { finished(error) } unless @token.nil?
52
+ end
53
+
54
+ private
55
+
56
+ def finished(error)
57
+ @retries = error.retries if error.is_a?(NotActionable)
58
+ { status: error ? :failed : :passed, waited_ms: Observer.millis(@started), retries: @retries,
59
+ error: error, element: @element }
60
+ end
61
+ end
62
+
63
+ class << self
64
+ # Yields a Run (nil without an observer); reports the outcome and re-raises whatever the body raised.
65
+ def emit_action(element, action, args = nil)
66
+ return yield(nil) if Raider.observer.nil? || Thread.current[GUARD_KEY]
67
+
68
+ run = Run.new(element, action, args)
69
+ begin
70
+ result = yield(run)
71
+ rescue Exception => e # rubocop:disable Lint/RescueException -- reports and re-raises, never swallows
72
+ run.finish(e)
73
+ raise
74
+ end
75
+ run.finish(nil)
76
+ result
77
+ end
78
+
79
+ def started(element, action, args)
80
+ stamp = locator_of(element)
81
+ { action: action, locator: stamp&.slice(:strategy, :selector), within: stamp ? stamp[:within] : [], args: args }
82
+ end
83
+
84
+ def locator_of(element)
85
+ element.instance_variable_get(:@raider_locator)
86
+ end
87
+
88
+ def stamp(element, args, parent)
89
+ attach(element, frozen_stamp(args, parent))
90
+ end
91
+
92
+ # One stamp per find, shared by every element it returned.
93
+ def stamp_all(elements, args, parent)
94
+ return elements if elements.empty?
95
+
96
+ shared = frozen_stamp(args, parent)
97
+ elements.each { |element| attach(element, shared) }
98
+ end
99
+
100
+ # Marks the observer as busy so an action taken inside a hook is not reported again.
101
+ def guarded
102
+ previous = Thread.current[GUARD_KEY]
103
+ Thread.current[GUARD_KEY] = true
104
+ yield
105
+ ensure
106
+ Thread.current[GUARD_KEY] = previous
107
+ end
108
+
109
+ # Prints one line and clears the observer, unless the observer was replaced meanwhile.
110
+ def stop(watcher, error)
111
+ $stderr.puts "[raider] observer stopped: #{summary(error)}" # rubocop:disable Style/StderrPuts -- must not be silenced by -W0
112
+ Raider.observer = nil if Raider.observer.equal?(watcher)
113
+ end
114
+
115
+ def millis(started)
116
+ ((clock - started) * 1000).round
117
+ end
118
+
119
+ def clock
120
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
121
+ end
122
+
123
+ private
124
+
125
+ def attach(element, stamp)
126
+ return element if !element.is_a?(Selenium::WebDriver::Element) || element.frozen?
127
+
128
+ element.instance_variable_set(:@raider_locator, stamp)
129
+ element
130
+ end
131
+
132
+ def frozen_stamp(args, parent)
133
+ stamp = describe(args, parent)
134
+ stamp[:within].each(&:freeze).freeze
135
+ stamp.freeze
136
+ end
137
+
138
+ # The class and the first line of the message; never raises, whatever #message does.
139
+ def summary(error)
140
+ line = error.message.to_s.lines.first.to_s.chomp
141
+ "#{error.class}: #{line[0, MESSAGE_LIMIT]}"
142
+ rescue StandardError, ScriptError, SystemStackError
143
+ error.class.to_s
144
+ end
145
+
146
+ def describe(args, parent)
147
+ how, what = args.size == 1 && args.first.is_a?(Hash) ? args.first.first : args
148
+ strategy = how.to_s
149
+ { strategy: -STRATEGIES.fetch(strategy, strategy).to_s, selector: -selector(strategy, what, args),
150
+ within: chain(parent) }
151
+ end
152
+
153
+ def selector(strategy, what, args)
154
+ return role_text(args) if strategy == 'role'
155
+
156
+ (what.is_a?(Array) ? what.first : what).to_s
157
+ end
158
+
159
+ def role_text(args)
160
+ role, options = RoleFinder.parse(args)
161
+ [role, *options.map { |key, value| "#{key}: #{value}" }].join(', ')
162
+ end
163
+
164
+ def chain(parent)
165
+ found = parent.is_a?(Selenium::WebDriver::Element) ? locator_of(parent) : nil
166
+ found ? [*found[:within], found.slice(:strategy, :selector)] : []
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'selenium-webdriver'
4
+
5
+ module Raider
6
+ # The in-page role engine and the query that calls it.
7
+ module RoleFinder
8
+ ENGINE = File.read(File.join(__dir__, 'js', 'role_engine.js')).freeze
9
+ QUERY = "if (!(window.__raider && window.__raider.role)) throw new Error('__raider not installed'); " \
10
+ 'return window.__raider.role(arguments[0] || document, arguments[1], arguments[2])'
11
+
12
+ NATIVE_MESSAGE = 'by_role needs a web context; use accessibility_id on native screens'
13
+
14
+ # Prepended to SearchContext: sends the :role key to the engine, everything else to Selenium.
15
+ def find_elements(*args)
16
+ role, options = RoleFinder.parse(args)
17
+ return super unless role
18
+
19
+ raise NotSupported, NATIVE_MESSAGE unless Context.web?(bridge)
20
+
21
+ RoleQuery.run(bridge, is_a?(Selenium::WebDriver::Driver) ? nil : self, role, options)
22
+ end
23
+
24
+ def find_element(*args)
25
+ return super unless RoleFinder.role_args?(args)
26
+
27
+ find_elements(*args).first or
28
+ raise Selenium::WebDriver::Error::NoSuchElementError, "no such element: #{Locator.describe(args)}"
29
+ end
30
+
31
+ def self.role_args?(args)
32
+ key = args.size == 1 && args.first.is_a?(Hash) ? args.first.keys.first : args.first
33
+ key.to_s == 'role'
34
+ end
35
+
36
+ # Returns [role, symbol-keyed options] for a role locator, nil for any other.
37
+ def self.parse(args)
38
+ return unless role_args?(args)
39
+
40
+ value = args.size == 1 ? args.first.values.first : args[1]
41
+ role, options = value.is_a?(Array) ? value : [value, nil]
42
+ raise ArgumentError, 'role: needs a role name and an optional options Hash' unless options.nil? || options.is_a?(Hash)
43
+
44
+ options = (options || {}).transform_keys(&:to_sym)
45
+ raise ArgumentError, "role: name must be a String, got #{options[:name].class}" unless [NilClass, String].include?(options[:name].class)
46
+
47
+ token = validated(role)
48
+ RoleLocator.validated_level(token, options[:level])
49
+ [token, options]
50
+ end
51
+
52
+ def self.validated(role)
53
+ raise ArgumentError, "role: needs a role name, got #{role.inspect}" unless role.is_a?(String) || role.is_a?(Symbol)
54
+
55
+ RoleLocator.validated_role(role)
56
+ end
57
+ end
58
+
59
+ # Runs a role query through the bridge, installing the engine on first use.
60
+ module RoleQuery
61
+ module_function
62
+
63
+ def run(bridge, scope, role, options)
64
+ options_js = options_js(options)
65
+ bridge.execute_script(RoleFinder::QUERY, scope, role, options_js)
66
+ rescue Selenium::WebDriver::Error::JavascriptError => e
67
+ raise unless e.message.include?('__raider')
68
+
69
+ bridge.execute_script("#{RoleFinder::ENGINE}\n#{RoleFinder::QUERY}", scope, role, options_js)
70
+ end
71
+
72
+ def options_js(options)
73
+ {
74
+ 'name' => options[:name],
75
+ 'exact' => options[:exact] ? true : false,
76
+ 'includeHidden' => options[:include_hidden] ? true : false,
77
+ 'level' => options[:level]
78
+ }
79
+ end
80
+ end
81
+ end
82
+
83
+ Selenium::WebDriver::SearchContext.prepend(Raider::RoleFinder)