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,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec'
4
+ require_relative '../../../lib/ruby_raider/runtime/rspec'
5
+
6
+ RSpec.describe 'Raider block expectations with value matchers' do
7
+ include Raider::Matchers
8
+
9
+ before { Raider.timeout = 0.3 }
10
+
11
+ after { Raider.reset }
12
+
13
+ let(:counter) do
14
+ Class.new do
15
+ attr_reader :calls
16
+
17
+ def initialize = @calls = 0
18
+ def next_value = @calls += 1
19
+ end.new
20
+ end
21
+
22
+ it 'retries a value matcher until the block value matches' do
23
+ expect { counter.next_value }.to eq(3)
24
+ expect(counter.calls).to eq(3)
25
+ end
26
+
27
+ it 'fails with the waited time and rspec own message' do
28
+ expect { expect { 1 }.to eq(2) }
29
+ .to raise_error(RSpec::Expectations::ExpectationNotMetError, /\(waited 0\.[34]\d?s\) \nexpected: 2\n got: 1/)
30
+ end
31
+
32
+ it 'fails a negated matcher with the waited time' do
33
+ expect { expect { 'x' }.not_to eq('x') }
34
+ .to raise_error(RSpec::Expectations::ExpectationNotMetError, /\(waited 0\.[34]\d?s\) \nexpected: value != "x"\n got: "x"/)
35
+ end
36
+
37
+ it 'retries not_to include until the value stops including it' do
38
+ values = [%w[x y], %w[x], %w[y]]
39
+ expect { values.size > 1 ? values.shift : values.first }.not_to include('x')
40
+ end
41
+
42
+ it 'retries to_not as well' do
43
+ values = [1, 1, 2]
44
+ expect { values.size > 1 ? values.shift : values.first }.to_not eq(1) # rubocop:disable RSpec/NotToNot
45
+ end
46
+
47
+ it 'retries match, be_empty and be > 3' do
48
+ n = 0
49
+ expect { (n += 1) > 1 ? 'abc' : 'x' }.to match(/abc/)
50
+ m = 0
51
+ expect { (m += 1) > 1 ? [] : [1] }.to be_empty
52
+ k = 0
53
+ expect { k += 1 }.to be > 3
54
+ end
55
+
56
+ it 'retries through lookup errors' do
57
+ calls = 0
58
+ result = proc do
59
+ raise Selenium::WebDriver::Error::NoSuchElementError, 'gone' if (calls += 1) <= 2
60
+
61
+ 'ok'
62
+ end
63
+ expect(&result).to eq('ok')
64
+ expect(calls).to eq(3)
65
+ end
66
+
67
+ it 'retries AmbiguousMatch and stale elements' do
68
+ calls = 0
69
+ expect do
70
+ calls += 1
71
+ raise Raider::AmbiguousMatch.new('d', []) if calls == 1
72
+ raise Selenium::WebDriver::Error::StaleElementReferenceError, 'stale' if calls == 2
73
+
74
+ 7
75
+ end.to eq(7)
76
+ end
77
+
78
+ it 're-raises the last lookup error when no value was ever read' do
79
+ expect { expect { raise Selenium::WebDriver::Error::NoSuchElementError, 'never' }.to eq(1) }
80
+ .to raise_error(Selenium::WebDriver::Error::NoSuchElementError, /never/)
81
+ end
82
+
83
+ it 'makes finds inside the block a single attempt' do
84
+ seen = nil
85
+ expect { seen = Raider::Wait.immediate? }.to be(true)
86
+ expect(seen).to be(true)
87
+ end
88
+
89
+ it 'evaluates raise_error once' do
90
+ calls = 0
91
+ expect { (calls += 1) && raise(ArgumentError) }.to raise_error(ArgumentError)
92
+ expect(calls).to eq(1)
93
+ end
94
+
95
+ it 'evaluates change once' do
96
+ list = []
97
+ calls = 0
98
+ expect { list << (calls += 1) }.to change { list.size }.by(1)
99
+ expect(calls).to eq(1)
100
+ end
101
+
102
+ it 'evaluates output once' do
103
+ calls = 0
104
+ expect { [calls += 1, print('a')] }.to output('a').to_stdout
105
+ expect(calls).to eq(1)
106
+ end
107
+
108
+ it 'evaluates throw_symbol once' do
109
+ calls = 0
110
+ expect { (calls += 1) && throw(:done) }.to throw_symbol(:done)
111
+ expect(calls).to eq(1)
112
+ end
113
+
114
+ it 'evaluates yield_control once' do
115
+ calls = 0
116
+ expect { |probe| (calls += 1) && probe.to_proc.call }.to yield_control
117
+ expect(calls).to eq(1)
118
+ end
119
+
120
+ it 'keeps a Raider matcher on a block with its own wait and message' do
121
+ Raider.timeout = 2
122
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
123
+ expect { expect { [1] }.to have_count(2, wait: 0.2) }
124
+ .to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected the block to have count 2, last seen 1/)
125
+ expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 1
126
+ end
127
+
128
+ it 'treats a timeout inside the block as not ready and re-raises it after the deadline' do
129
+ expect { expect { Raider::Wait.until(timeout: 0.05) { false } }.to eq(1) }
130
+ .to raise_error(Raider::TimeoutError, /condition not met/)
131
+ end
132
+
133
+ it 'passes once a block that raised a Raider::TimeoutError twice returns the value' do
134
+ calls = 0
135
+ expect { (calls += 1) <= 2 ? raise(Raider::TimeoutError, 'slow') : 5 }.to eq(5)
136
+ end
137
+
138
+ it 'reports the last poll error rather than an older value' do
139
+ calls = 0
140
+ block = proc do
141
+ raise Selenium::WebDriver::Error::NoSuchElementError, 'later gone' if (calls += 1) > 1
142
+
143
+ 'x'
144
+ end
145
+ expect { expect(&block).to eq('y') }
146
+ .to raise_error(Selenium::WebDriver::Error::NoSuchElementError, /later gone/)
147
+ end
148
+
149
+ it 'reports the last value when the last poll read one' do
150
+ calls = 0
151
+ block = proc do
152
+ raise Selenium::WebDriver::Error::NoSuchElementError, 'early' if (calls += 1) == 1
153
+
154
+ 'x'
155
+ end
156
+ expect { expect(&block).to eq('y') }
157
+ .to raise_error(RSpec::Expectations::ExpectationNotMetError, /got: "x"/)
158
+ end
159
+
160
+ it 'does not chain the internal timeout as a cause' do
161
+ expect { expect { 1 }.to eq(2) }.to raise_error(RSpec::Expectations::ExpectationNotMetError) { |error|
162
+ expect(error.cause).to be_nil
163
+ }
164
+ end
165
+ end
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec'
4
+ require_relative '../../lib/ruby_raider/runtime'
5
+ require_relative 'support/fake_bridge'
6
+
7
+ RSpec.describe 'Raider::Observer' do
8
+ let(:bridge) { Raider::Spec::FakeBridge.new }
9
+ let(:driver) { Raider::Spec.driver(bridge) }
10
+ let(:recorder_class) do
11
+ Class.new do
12
+ attr_reader :events
13
+
14
+ def initialize = @events = []
15
+
16
+ def action_started(**details)
17
+ @events << [:action_started, details]
18
+ :token
19
+ end
20
+
21
+ def action_ready(token, **details) = @events << [:action_ready, token, details]
22
+ def action_finished(token, **details) = @events << [:action_finished, token, details]
23
+ end
24
+ end
25
+ let(:recorder) { recorder_class.new }
26
+
27
+ before do
28
+ Raider.reset
29
+ Raider::Actionability.reset_notice
30
+ allow($stderr).to receive(:puts)
31
+ allow(Raider::Describe).to receive(:element) { |element| "<el #{element.ref.last}>" }
32
+ Raider.observer = recorder
33
+ end
34
+
35
+ after { Raider.reset }
36
+
37
+ def names = recorder.events.map(&:first)
38
+ def finished = recorder.events.last.last
39
+
40
+ it 'reports a role click with its retries' do
41
+ bridge.script_results.push([Selenium::WebDriver::Element.new(bridge, 'a')])
42
+ button = driver.find_element(Raider.by_role('button', name: 'Save'))
43
+ bridge.script_results.push({ 'check' => 'stable', 'first' => false }, { 'check' => 'stable', 'first' => false }, nil)
44
+ button.click
45
+ started, ready, done = recorder.events
46
+ expect(started).to eq([:action_started, { action: :click, locator: { strategy: 'role', selector: 'button, name: Save' }, within: [], args: nil }])
47
+ expect(ready).to eq([:action_ready, :token, { element: button }])
48
+ expect(done[1]).to eq(:token)
49
+ expect(done[2]).to include(status: :passed, retries: 2, error: nil, element: button)
50
+ expect(done[2][:waited_ms]).to be_a(Integer)
51
+ end
52
+
53
+ it 'hands the observer a frozen copy of the keys so it cannot change what is typed' do
54
+ keys = ['a']
55
+ seen = nil
56
+ recorder.define_singleton_method(:action_started) { |**details| (seen = details[:args]) << 'evil' }
57
+ bridge.enqueue(['f'])
58
+ driver.find_element(id: 'x').send_keys(*keys)
59
+ expect(seen).to be_frozen
60
+ expect(bridge.calls.select { |call| call.first == :send_keys_to_element }.map(&:last)).to eq([['a']])
61
+ end
62
+
63
+ it 'reports a nested send_keys with its within chain and keys' do
64
+ bridge.enqueue(['f'])
65
+ form = driver.find_element(css: 'form')
66
+ bridge.enqueue(['i'])
67
+ field = form.find_element(id: 'email')
68
+ bridge.script_results.push(nil)
69
+ field.send_keys('a')
70
+ expect(recorder.events.first.last).to eq(
71
+ action: :send_keys, locator: { strategy: 'id', selector: 'email' },
72
+ within: [{ strategy: 'css', selector: 'form' }], args: ['a']
73
+ )
74
+ end
75
+
76
+ it 'reports clear and submit with nil args' do
77
+ bridge.enqueue(['a'])
78
+ el = driver.find_element(name: 'q')
79
+ bridge.script_results.push(nil, nil)
80
+ el.clear
81
+ el.submit
82
+ expect(recorder.events.select { |e| e.first == :action_started }.map { |e| [e.last[:action], e.last[:args]] })
83
+ .to eq([[:clear, nil], [:submit, nil]])
84
+ end
85
+
86
+ it 'reports a failed timeout with the error and re-raises it' do
87
+ Raider.timeout = 0.3
88
+ bridge.enqueue(['a'])
89
+ el = driver.find_element(id: 'x')
90
+ bridge.script_results.concat([{ 'check' => 'visible' }] * 50)
91
+ expect { el.click }.to raise_error(Raider::NotActionable) { |error|
92
+ expect(finished).to include(status: :failed, error: error)
93
+ expect(finished[:retries]).to eq(error.retries)
94
+ expect(error.retries).to be >= 1
95
+ }
96
+ expect(names).to eq(%i[action_started action_finished])
97
+ end
98
+
99
+ it 're-raises a non-StandardError after reporting it' do
100
+ el = Selenium::WebDriver::Element.new(bridge, 'e1')
101
+ bridge.script_results.push(Interrupt.new('stop'))
102
+ expect { el.click }.to raise_error(Interrupt)
103
+ expect(finished).to include(status: :failed)
104
+ expect(finished[:error]).to be_a(Interrupt)
105
+ end
106
+
107
+ it 'still reports with force: true' do
108
+ bridge.answer(:element_tag_name, 'div')
109
+ Selenium::WebDriver::Element.new(bridge, 'e1').click(force: true)
110
+ expect(names).to eq(%i[action_started action_ready action_finished])
111
+ expect(finished).to include(status: :passed, retries: 0)
112
+ end
113
+
114
+ it 'still reports with actionability off' do
115
+ Raider.actionability = false
116
+ Selenium::WebDriver::Element.new(bridge, 'e1').click
117
+ expect(names).to eq(%i[action_started action_ready action_finished])
118
+ end
119
+
120
+ it 'still reports a native click' do
121
+ appium = Class.new(Raider::Spec::FakeBridge) { def current_context = 'NATIVE_APP' }.new
122
+ appium.answer(:element_displayed?, false, true).answer(:element_enabled?, true)
123
+ Selenium::WebDriver::Element.new(appium, 'e1').click
124
+ expect(names).to eq(%i[action_started action_ready action_finished])
125
+ expect(finished[:retries]).to eq(1)
126
+ end
127
+
128
+ it 'gives a nil locator to an unstamped element' do
129
+ bridge.answer(:element_tag_name, 'div')
130
+ Selenium::WebDriver::Element.new(bridge, 'e1').click(force: true)
131
+ expect(recorder.events.first.last).to include(locator: nil, within: [])
132
+ end
133
+
134
+ it 'lets the click happen when the observer raises, prints once and clears it' do
135
+ recorder.define_singleton_method(:action_started) { |**| raise ArgumentError, 'boom' }
136
+ bridge.answer(:element_tag_name, 'div')
137
+ Selenium::WebDriver::Element.new(bridge, 'e1').click(force: true)
138
+ expect(bridge.calls.last).to eq([:click_element, 'e1'])
139
+ expect($stderr).to have_received(:puts).with('[raider] observer stopped: ArgumentError: boom').once
140
+ expect(Raider.observer).to be_nil
141
+ end
142
+
143
+ it 'passes the token the observer returned back to later events' do
144
+ bridge.answer(:element_tag_name, 'div')
145
+ Selenium::WebDriver::Element.new(bridge, 'e1').click(force: true)
146
+ expect(recorder.events.map { |e| e[1] }.drop(1)).to eq(%i[token token])
147
+ end
148
+
149
+ describe 'a broken observer' do
150
+ let(:element) { Selenium::WebDriver::Element.new(bridge, 'e1') }
151
+
152
+ before { bridge.answer(:element_tag_name, 'div') }
153
+
154
+ [NotImplementedError, SystemStackError, ArgumentError].each do |klass|
155
+ %i[action_started action_ready action_finished].each do |hook|
156
+ it "survives #{klass} in #{hook}: the click happens, one line, observer cleared" do
157
+ recorder.define_singleton_method(hook) { |*, **| raise klass, 'boom' }
158
+ element.click(force: true)
159
+ expect(bridge.calls.last).to eq([:click_element, 'e1'])
160
+ expect($stderr).to have_received(:puts).with("[raider] observer stopped: #{klass}: boom").once
161
+ expect(Raider.observer).to be_nil
162
+ end
163
+ end
164
+ end
165
+
166
+ it 'lets an Interrupt from the observer propagate' do
167
+ recorder.define_singleton_method(:action_started) { |**| raise Interrupt }
168
+ expect { element.click(force: true) }.to raise_error(Interrupt)
169
+ end
170
+
171
+ it 'truncates the message to its first line and 200 characters' do
172
+ recorder.define_singleton_method(:action_started) { |**| raise ArgumentError, "#{'x' * 500}\nsecond" }
173
+ element.click(force: true)
174
+ expect($stderr).to have_received(:puts).with("[raider] observer stopped: ArgumentError: #{'x' * 200}")
175
+ end
176
+
177
+ it 'survives an exception whose message raises' do
178
+ error = Class.new(StandardError) { def message = raise('no') }
179
+ recorder.define_singleton_method(:action_started) { |**| raise error }
180
+ expect { element.click(force: true) }.not_to raise_error
181
+ expect(Raider.observer).to be_nil
182
+ end
183
+
184
+ it 'leaves a replacement observer installed' do
185
+ other = recorder_class.new
186
+ recorder.define_singleton_method(:action_started) do |**|
187
+ Raider.observer = other
188
+ raise 'swap'
189
+ end
190
+ element.click(force: true)
191
+ expect(Raider.observer).to equal(other)
192
+ end
193
+
194
+ it 'does not report again for an action taken inside a hook' do
195
+ inner = Selenium::WebDriver::Element.new(bridge, 'e2')
196
+ started = 0
197
+ recorder.define_singleton_method(:action_started) do |**|
198
+ started += 1
199
+ inner.click(force: true)
200
+ :token
201
+ end
202
+ element.click(force: true)
203
+ expect(bridge.calls.select { |c| c.first == :click_element }).to eq([[:click_element, 'e2'], [:click_element, 'e1']])
204
+ expect(started).to eq(1)
205
+ end
206
+
207
+ it 'sends no follow-up events when action_started returns nil' do
208
+ events = []
209
+ recorder.define_singleton_method(:action_started) { |**| nil }
210
+ recorder.define_singleton_method(:action_ready) { |*, **| events << :ready }
211
+ recorder.define_singleton_method(:action_finished) { |*, **| events << :finished }
212
+ element.click(force: true)
213
+ expect(events).to be_empty
214
+ end
215
+ end
216
+
217
+ describe 'stamping' do
218
+ it 'shares one frozen stamp across a find_elements result' do
219
+ bridge.enqueue(%w[a b c])
220
+ stamps = driver.find_elements(id: 'x').map { |e| e.instance_variable_get(:@raider_locator) }
221
+ expect(stamps.map(&:object_id).uniq.size).to eq(1)
222
+ expect([stamps.first.frozen?, stamps.first[:within].frozen?]).to eq([true, true])
223
+ end
224
+
225
+ it 'deep-freezes the stamp and keeps the caller string out of it' do
226
+ bridge.enqueue(['a'])
227
+ selector = +'#login'
228
+ stamp = driver.find_element(css: selector).instance_variable_get(:@raider_locator)
229
+ expect([stamp[:strategy].frozen?, stamp[:selector].frozen?]).to eq([true, true])
230
+ expect(stamp[:selector]).not_to equal(selector)
231
+ selector << '-changed'
232
+ expect(stamp[:selector]).to eq('#login')
233
+ end
234
+
235
+ it 'leaves a frozen element unstamped without raising' do
236
+ element = Selenium::WebDriver::Element.new(bridge, 'a').freeze
237
+ expect(Raider::Observer.stamp(element, [{ id: 'x' }], driver)).to equal(element)
238
+ expect(Raider::Observer.locator_of(element)).to be_nil
239
+ end
240
+
241
+ it 'chains three levels, outermost first' do
242
+ %w[m f i].each { |id| bridge.enqueue([id]) }
243
+ field = driver.find_element(css: 'main').find_element(css: 'form').find_element(id: 'email')
244
+ expect(Raider::Observer.locator_of(field)[:within]).to eq(
245
+ [{ strategy: 'css', selector: 'main' }, { strategy: 'css', selector: 'form' }]
246
+ )
247
+ end
248
+
249
+ it 'stamps through the first alias' do
250
+ bridge.enqueue(['a'])
251
+ expect(Raider::Observer.locator_of(driver.first(id: 'x'))).to include(strategy: 'id', selector: 'x')
252
+ end
253
+
254
+ it 'takes the first item of an Array-form selector through a real find' do
255
+ bridge.enqueue(['a'])
256
+ found = driver.find_element(text: ['Save', { exact: true }])
257
+ expect(Raider::Observer.locator_of(found)).to include(strategy: 'text', selector: 'Save')
258
+ end
259
+ end
260
+
261
+ describe 'Raider.report' do
262
+ it 'returns nil without an observer or when the hook is missing' do
263
+ Raider.observer = Object.new
264
+ expect(Raider.report(:nothing)).to be_nil
265
+ Raider.observer = nil
266
+ expect(Raider.report(:nothing) { raise 'not built' }).to be_nil
267
+ end
268
+
269
+ it 'builds the details from the block only for an observer that responds' do
270
+ seen = nil
271
+ Raider.observer = Object.new.tap { |o| o.define_singleton_method(:hook) { |first, **d| seen = [first, d] } }
272
+ Raider.report(:hook, 1) { { a: 2 } }
273
+ expect(seen).to eq([1, { a: 2 }])
274
+ end
275
+
276
+ it 'catches a failing block under the same rescue' do
277
+ Raider.observer = recorder
278
+ Raider.report(:action_started) { raise 'bad' }
279
+ expect($stderr).to have_received(:puts).with('[raider] observer stopped: RuntimeError: bad')
280
+ expect(Raider.observer).to be_nil
281
+ end
282
+ end
283
+
284
+ describe 'with no observer' do
285
+ before { Raider.observer = nil }
286
+
287
+ it 'sends the same bridge commands as a bare click' do
288
+ bridge.script_results.push({ 'check' => 'visible' }, nil)
289
+ Selenium::WebDriver::Element.new(bridge, 'e1').click
290
+ expect(bridge.calls).to eq([[:click_element, 'e1']])
291
+ expect(bridge.script_calls.size).to eq(2)
292
+ end
293
+
294
+ it 'yields without a run' do
295
+ expect { |block| Raider::Observer.emit_action(nil, :click, &block) }.to yield_with_args(nil)
296
+ end
297
+ end
298
+
299
+ describe '.locator_of' do
300
+ # Straight through the stamp: Selenium rejects some of these spellings before any find succeeds.
301
+ def locate(*args)
302
+ element = Selenium::WebDriver::Element.new(bridge, 'a')
303
+ Raider::Observer.locator_of(Raider::Observer.stamp(element, args, driver))
304
+ end
305
+
306
+ {
307
+ [:css_selector, 'a'] => %w[css a], [{ class: 'k' }] => %w[class_name k], [{ link: 'l' }] => %w[link_text l],
308
+ [:partial_link, 'p'] => %w[partial_link_text p], ['class name', 'k'] => %w[class_name k],
309
+ ['css selector', 'a'] => %w[css a], ['link text', 'l'] => %w[link_text l],
310
+ ['partial link text', 'p'] => %w[partial_link_text p], ['tag name', 'div'] => %w[tag_name div],
311
+ [:id, 'x'] => %w[id x], [{ xpath: '//a' }] => ['xpath', '//a'], [{ label: 'Email' }] => %w[label Email],
312
+ [{ test_id: 't' }] => %w[test_id t], [{ text: ['Hi', { exact: true }] }] => %w[text Hi]
313
+ }.each do |args, (strategy, selector)|
314
+ it "normalises #{args.inspect}" do
315
+ expect(locate(*args)).to eq(strategy: strategy, selector: selector, within: [])
316
+ end
317
+ end
318
+
319
+ it 'renders a role with its options' do
320
+ expect(locate(role: ['button', { name: 'Save', exact: true }])[:selector]).to eq('button, name: Save, exact: true')
321
+ end
322
+
323
+ it 'renders a bare role' do
324
+ expect(locate(role: 'button')).to include(strategy: 'role', selector: 'button')
325
+ end
326
+
327
+ it 'stamps find_elements results too' do
328
+ bridge.enqueue(%w[a b])
329
+ found = driver.find_elements(id: 'x')
330
+ expect(found.map { |e| Raider::Observer.locator_of(e) }).to all(include(strategy: 'id', selector: 'x'))
331
+ end
332
+
333
+ it 'returns nil for an unstamped element' do
334
+ expect(Raider::Observer.locator_of(Selenium::WebDriver::Element.new(bridge, 'e1'))).to be_nil
335
+ end
336
+ end
337
+
338
+ it 'names the runtime directory as LIB_DIR' do
339
+ expect(Raider::Observer::LIB_DIR).to eq(File.expand_path('../../lib/ruby_raider/runtime', __dir__))
340
+ end
341
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec'
4
+ require_relative '../../lib/ruby_raider/runtime'
5
+ require_relative 'support/fake_bridge'
6
+
7
+ RSpec.describe 'Raider role finder' do
8
+ let(:bridge) { Raider::Spec::FakeBridge.new }
9
+ let(:driver) { Raider::Spec.driver(bridge) }
10
+ let(:locator) { Raider.by_role('button', name: 'Save') }
11
+ let(:options) { { 'name' => 'Save', 'exact' => false, 'includeHidden' => false, 'level' => nil } }
12
+
13
+ before { allow(Raider::Describe).to receive(:element) { |element| "<el #{element.ref.last}>" } }
14
+
15
+ after { Raider.reset }
16
+
17
+ # These examples are about the role engine, so each find makes one attempt.
18
+ around { |example| Raider::Wait.immediately { example.run } }
19
+
20
+ def element(id)
21
+ Selenium::WebDriver::Element.new(bridge, id)
22
+ end
23
+
24
+ it 'queries the engine with a nil scope from the driver' do
25
+ bridge.script_results << [element('a')]
26
+ driver.find_elements(locator)
27
+ expect(bridge.script_calls.first.drop(1)).to eq([nil, 'button', options])
28
+ end
29
+
30
+ it 'scopes the query to the element' do
31
+ scope = element('s')
32
+ bridge.script_results << []
33
+ scope.find_elements(Raider.by_role('listitem'))
34
+ expect(bridge.script_calls.first.drop(1)).to eq([scope, 'listitem', { 'name' => nil, 'exact' => false,
35
+ 'includeHidden' => false, 'level' => nil }])
36
+ end
37
+
38
+ it 'scopes the query to a shadow root instead of the document' do
39
+ shadow = Selenium::WebDriver::ShadowRoot.new(bridge, 'sr')
40
+ bridge.script_results << []
41
+ shadow.find_elements(Raider.by_role('button'))
42
+ expect(bridge.script_calls.first[1]).to equal(shadow)
43
+ end
44
+
45
+ it 'carries a heading level and rejects bad ones before any script runs' do
46
+ bridge.script_results << []
47
+ driver.find_elements(Raider.by_role('heading', level: 2))
48
+ expect(bridge.script_calls.first.last).to include('level' => 2)
49
+ [{ role: ['button', { level: 2 }] }, { role: ['heading', { level: 7 }] }, { role: ['heading', { level: '2' }] }].each do |bad|
50
+ expect { driver.find_elements(bad) }.to raise_error(ArgumentError, /level/)
51
+ end
52
+ expect(bridge.script_calls.size).to eq(1)
53
+ end
54
+
55
+ it 'accepts the two-argument form and string option keys' do
56
+ bridge.script_results << []
57
+ driver.find_elements(:role, ['button', { 'name' => 'Save' }])
58
+ expect(bridge.script_calls.first.drop(1)).to eq([nil, 'button', options])
59
+ end
60
+
61
+ it 'accepts a bare role string' do
62
+ bridge.script_results << []
63
+ driver.find_elements(role: 'button')
64
+ expect(bridge.script_calls.first[2]).to eq('button')
65
+ end
66
+
67
+ it 'accepts a string role key in both forms' do
68
+ bridge.script_results.push([], [])
69
+ driver.find_elements('role' => ['button', { name: 'Save' }])
70
+ driver.find_elements('role', ['button', { name: 'Save' }])
71
+ expect(bridge.script_calls.map { |call| call.drop(1) }).to eq([[nil, 'button', options]] * 2)
72
+ end
73
+
74
+ it 'finds a single element through a string role key' do
75
+ bridge.script_results << [element('a')]
76
+ expect(driver.find_element('role' => 'button').ref.last).to eq('a')
77
+ end
78
+
79
+ {
80
+ 'a nil value' => { role: nil },
81
+ 'an unknown role' => { role: 'buton' },
82
+ 'a Hash value' => { role: { a: 1 } },
83
+ 'a blank role' => { role: ['', { name: 'x' }] },
84
+ 'non-Hash options' => { role: %w[button Save] },
85
+ 'a Regexp name' => { role: ['button', { name: /Save/ }] }
86
+ }.each do |label, locator|
87
+ it "raises ArgumentError for #{label} before any script runs" do
88
+ expect { driver.find_elements(locator) }.to raise_error(ArgumentError, /role|unknown role/)
89
+ expect { driver.find_element(locator) }.to raise_error(ArgumentError)
90
+ expect(bridge.script_calls).to be_empty
91
+ end
92
+ end
93
+
94
+ it 'raises AmbiguousMatch from find_element on two matches' do
95
+ bridge.script_results << [element('a'), element('b')]
96
+ expect { driver.find_element(locator) }.to raise_error(Raider::AmbiguousMatch) { |error|
97
+ expect(error.message).to start_with('`by_role(\'button\', name: "Save")` matched 2 elements')
98
+ }
99
+ end
100
+
101
+ it 'returns the single match' do
102
+ bridge.script_results << [element('a')]
103
+ expect(driver.find_element(locator).ref.last).to eq('a')
104
+ end
105
+
106
+ it 'returns the first match when strict is off' do
107
+ Raider.strict = false
108
+ bridge.script_results << [element('a'), element('b')]
109
+ expect(driver.find_element(locator).ref.last).to eq('a')
110
+ end
111
+
112
+ it 'raises NoSuchElementError with no matches' do
113
+ bridge.script_results << []
114
+ expect { driver.find_element(locator) }
115
+ .to raise_error(Selenium::WebDriver::Error::NoSuchElementError, /no such element: by_role\('button', name: "Save"\)/)
116
+ end
117
+
118
+ it 'raises NoSuchElementError with no matches when strict is off' do
119
+ Raider.strict = false
120
+ bridge.script_results << []
121
+ expect { driver.find_element(locator) }
122
+ .to raise_error(Selenium::WebDriver::Error::NoSuchElementError, /no such element: by_role/)
123
+ end
124
+
125
+ it 'propagates a JavascriptError that is not about the engine' do
126
+ bridge.script_results << Selenium::WebDriver::Error::JavascriptError.new('boom')
127
+ expect { driver.find_elements(locator) }.to raise_error(Selenium::WebDriver::Error::JavascriptError, 'boom')
128
+ end
129
+
130
+ it 'leaves other locators for Selenium' do
131
+ bridge.enqueue(%w[a b])
132
+ expect(driver.find_elements(css: 'x').size).to eq(2)
133
+ expect(bridge.calls.first.first).to eq(:find_elements_by)
134
+ expect(bridge.script_calls).to be_empty
135
+ end
136
+
137
+ context 'with an Appium NATIVE_APP bridge' do
138
+ let(:bridge) do
139
+ klass = stub_const('Appium::Core::Base::Bridge', Class.new(Raider::Spec::FakeBridge) do
140
+ class << self; attr_accessor :locator_converter; end
141
+ define_method(:current_context) { 'NATIVE_APP' }
142
+ end)
143
+ klass.locator_converter = Selenium::WebDriver::Remote::Bridge::LocatorConverter.new
144
+ klass.new
145
+ end
146
+
147
+ it 'raises NotSupported' do
148
+ expect { driver.find_elements(locator) }
149
+ .to raise_error(Raider::NotSupported, 'by_role needs a web context; use accessibility_id on native screens')
150
+ end
151
+
152
+ it 'raises NotSupported from find_element too' do
153
+ expect { driver.find_element(locator) }.to raise_error(Raider::NotSupported)
154
+ end
155
+ end
156
+ end