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,75 @@
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::RoleQuery' do
8
+ let(:bridge) { Raider::Spec::FakeBridge.new }
9
+ let(:elements) { %w[a b] }
10
+ let(:missing) { Selenium::WebDriver::Error::JavascriptError.new('ReferenceError: window.__raider is undefined') }
11
+
12
+ it 'queries once when the engine is already installed' do
13
+ bridge.script_results << elements
14
+ expect(Raider::RoleQuery.run(bridge, nil, 'button', {})).to eq(elements)
15
+ expect(bridge.script_calls.length).to eq(1)
16
+ expect(bridge.script_calls.first.first).to eq(Raider::RoleFinder::QUERY)
17
+ end
18
+
19
+ it 'installs the engine once and re-runs the query when it is missing' do
20
+ bridge.script_results.push(missing, elements)
21
+ expect(Raider::RoleQuery.run(bridge, nil, 'button', {})).to eq(elements)
22
+ expect(bridge.script_calls.length).to eq(2)
23
+ expect(bridge.script_calls.last.first).to start_with(Raider::RoleFinder::ENGINE)
24
+ expect(bridge.script_calls.last.first).to end_with(Raider::RoleFinder::QUERY)
25
+ end
26
+
27
+ it 'installs the engine on the Chromium-style missing-engine error' do
28
+ bridge.script_results.push(Selenium::WebDriver::Error::JavascriptError.new('javascript error: __raider not installed'),
29
+ elements)
30
+ expect(Raider::RoleQuery.run(bridge, nil, 'button', {})).to eq(elements)
31
+ expect(bridge.script_calls.length).to eq(2)
32
+ end
33
+
34
+ it 'guards the query so a missing engine throws a fixed message' do
35
+ expect(Raider::RoleFinder::QUERY).to include('__raider not installed')
36
+ end
37
+
38
+ it 'does not retry beyond the install run' do
39
+ bridge.script_results.push(missing, missing)
40
+ expect { Raider::RoleQuery.run(bridge, nil, 'button', {}) }
41
+ .to raise_error(Selenium::WebDriver::Error::JavascriptError)
42
+ expect(bridge.script_calls.length).to eq(2)
43
+ end
44
+
45
+ it 'propagates an unrelated JavascriptError' do
46
+ bridge.script_results << Selenium::WebDriver::Error::JavascriptError.new('TypeError: boom')
47
+ expect { Raider::RoleQuery.run(bridge, nil, 'button', {}) }
48
+ .to raise_error(Selenium::WebDriver::Error::JavascriptError, /boom/)
49
+ expect(bridge.script_calls.length).to eq(1)
50
+ end
51
+
52
+ it 'passes the scope, role and camelCase options' do
53
+ bridge.script_results << elements
54
+ Raider::RoleQuery.run(bridge, :scope, 'link', name: 'Save', exact: true, include_hidden: true)
55
+ expect(bridge.script_calls.first.drop(1))
56
+ .to eq([:scope, 'link', { 'name' => 'Save', 'exact' => true, 'includeHidden' => true, 'level' => nil }])
57
+ end
58
+
59
+ it 'always carries all four option keys' do
60
+ bridge.script_results << elements
61
+ Raider::RoleQuery.run(bridge, nil, 'link', {})
62
+ expect(bridge.script_calls.first.last).to eq('name' => nil, 'exact' => false, 'includeHidden' => false,
63
+ 'level' => nil)
64
+ end
65
+
66
+ it 'carries the heading level' do
67
+ bridge.script_results << elements
68
+ Raider::RoleQuery.run(bridge, nil, 'heading', level: 3)
69
+ expect(bridge.script_calls.first.last).to include('level' => 3)
70
+ end
71
+
72
+ it 'exposes the engine source as a frozen string' do
73
+ expect(Raider::RoleFinder::ENGINE).to be_frozen.and include('__raider')
74
+ end
75
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec'
4
+ require_relative '../../lib/ruby_raider/runtime/selectors'
5
+
6
+ RSpec.describe 'Raider::Selectors' do
7
+ let(:controls) do
8
+ '(self::input or self::textarea or self::select or self::button or ' \
9
+ "self::meter or self::output or self::progress) and not(self::input[@type='hidden'])"
10
+ end
11
+ let(:excluded) do
12
+ 'not(self::script or self::style or self::noscript or self::template or self::title or self::head) ' \
13
+ 'and not(ancestor::head)'
14
+ end
15
+
16
+ describe '.xpath_literal' do
17
+ it 'single-quotes a value without single quotes' do
18
+ expect(Raider::Selectors.xpath_literal('Sign in')).to eq("'Sign in'")
19
+ end
20
+
21
+ it 'double-quotes a value with only single quotes' do
22
+ expect(Raider::Selectors.xpath_literal("O'Neil")).to eq(%q("O'Neil"))
23
+ end
24
+
25
+ it 'single-quotes a value with only double quotes' do
26
+ expect(Raider::Selectors.xpath_literal('say "hi"')).to eq(%q('say "hi"'))
27
+ end
28
+
29
+ it 'uses concat when both quote kinds appear' do
30
+ expect(Raider::Selectors.xpath_literal(%q(O'Neil "the" boss)))
31
+ .to eq(%q(concat('O', "'", 'Neil "the" boss')))
32
+ end
33
+
34
+ it 'handles leading, trailing and repeated single quotes with concat' do
35
+ expect(Raider::Selectors.xpath_literal(%q('a''"b')))
36
+ .to eq(%q(concat('', "'", 'a', "'", '', "'", '"b', "'", '')))
37
+ end
38
+
39
+ it 'converts non-strings with to_s' do
40
+ expect(Raider::Selectors.xpath_literal(42)).to eq("'42'")
41
+ end
42
+ end
43
+
44
+ describe '.label' do
45
+ it 'builds the contains union by default' do
46
+ expect(Raider::Selectors.label('Email', exact: false)).to eq(
47
+ ".//*[@id = //label[contains(normalize-space(.), 'Email')]/@for] | " \
48
+ ".//label[contains(normalize-space(.), 'Email')]//*[#{controls}] | " \
49
+ ".//*[contains(normalize-space(@aria-label), 'Email')] | " \
50
+ ".//*[@aria-labelledby and contains(normalize-space(string(id(@aria-labelledby))), 'Email')]"
51
+ )
52
+ end
53
+
54
+ it 'builds the equality union with exact: true' do
55
+ expect(Raider::Selectors.label('Email', exact: true)).to eq(
56
+ ".//*[@id = //label[normalize-space(.) = 'Email']/@for] | " \
57
+ ".//label[normalize-space(.) = 'Email']//*[#{controls}] | " \
58
+ ".//*[normalize-space(@aria-label) = 'Email'] | " \
59
+ ".//*[@aria-labelledby and normalize-space(string(id(@aria-labelledby))) = 'Email']"
60
+ )
61
+ end
62
+
63
+ it 'does not let a quote break out of the literal' do
64
+ xpath = Raider::Selectors.label("x'] | //*['", exact: false)
65
+ expect(xpath).to include(%q("x'] | //*['"))
66
+ end
67
+ end
68
+
69
+ describe '.text' do
70
+ it 'builds the innermost-match xpath' do
71
+ expect(Raider::Selectors.text('Sign in', exact: false)).to eq(
72
+ ".//*[#{excluded} and contains(normalize-space(.), 'Sign in') and " \
73
+ "not(.//*[#{excluded} and contains(normalize-space(.), 'Sign in')])]"
74
+ )
75
+ end
76
+
77
+ it 'uses equality with exact: true' do
78
+ expect(Raider::Selectors.text('Sign in', exact: true)).to eq(
79
+ ".//*[#{excluded} and normalize-space(.) = 'Sign in' and " \
80
+ "not(.//*[#{excluded} and normalize-space(.) = 'Sign in'])]"
81
+ )
82
+ end
83
+
84
+ it 'uses concat for a value with both quote kinds' do
85
+ literal = %q(concat('O', "'", 'Neil "the" boss'))
86
+ expect(Raider::Selectors.text(%q(O'Neil "the" boss), exact: false)).to eq(
87
+ ".//*[#{excluded} and contains(normalize-space(.), #{literal}) and " \
88
+ "not(.//*[#{excluded} and contains(normalize-space(.), #{literal})])]"
89
+ )
90
+ end
91
+
92
+ it 'single-quotes a value with only double quotes' do
93
+ expect(Raider::Selectors.text('say "hi"', exact: true)).to include(%q(= 'say "hi"'))
94
+ end
95
+ end
96
+
97
+ describe '.attribute_css' do
98
+ it 'escapes the inner quote for a contains match' do
99
+ expect(Raider::Selectors.attribute_css('placeholder', 'a[b]"c', exact: false))
100
+ .to eq('[placeholder*="a[b]\"c"]')
101
+ end
102
+
103
+ it 'uses equality when exact' do
104
+ expect(Raider::Selectors.attribute_css('title', 'Hi', exact: true)).to eq('[title="Hi"]')
105
+ end
106
+
107
+ it 'escapes backslashes before quotes' do
108
+ expect(Raider::Selectors.attribute_css('alt', 'a\\"b', exact: true))
109
+ .to eq('[alt="a\\\\\\"b"]')
110
+ end
111
+
112
+ it 'rejects an invalid attribute name' do
113
+ expect { Raider::Selectors.attribute_css('a]b', 'x', exact: true) }
114
+ .to raise_error(ArgumentError, 'invalid attribute name: a]b')
115
+ end
116
+ end
117
+
118
+ describe '.test_id' do
119
+ after { Raider.reset }
120
+
121
+ it 'uses the default attribute' do
122
+ expect(Raider::Selectors.test_id('x')).to eq('[data-testid="x"]')
123
+ end
124
+
125
+ it 'uses a configured attribute' do
126
+ Raider.test_id_attribute = 'data-qa'
127
+ expect(Raider::Selectors.test_id('x')).to eq('[data-qa="x"]')
128
+ end
129
+
130
+ it 'rejects a configured attribute that is not a plain name' do
131
+ Raider.test_id_attribute = 'data-qa]'
132
+ expect { Raider::Selectors.test_id('x') }.to raise_error(ArgumentError, /invalid attribute name/)
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,257 @@
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 strict find_element' do
8
+ let(:bridge) { Raider::Spec::FakeBridge.new }
9
+ let(:driver) { Raider::Spec.driver(bridge) }
10
+
11
+ before { allow(Raider::Describe).to receive(:element) { |element| "<el #{element.ref.last}>" } }
12
+
13
+ after { Raider.reset }
14
+
15
+ it 'raises AmbiguousMatch listing both matches' do
16
+ bridge.enqueue(%w[a b])
17
+ expect { driver.find_element(label: 'Email') }.to raise_error(Raider::AmbiguousMatch) { |error|
18
+ expect(error.message).to include('label: "Email"')
19
+ expect(error.message).to include('matched 2 elements', '<el a>', '<el b>')
20
+ }
21
+ end
22
+
23
+ it 'returns the single match through one find_elements_by call' do
24
+ bridge.enqueue(['a'])
25
+ expect(driver.find_element(label: 'Email')).to be_a(Selenium::WebDriver::Element)
26
+ expect(bridge.calls.map(&:first)).to eq([:find_elements_by])
27
+ end
28
+
29
+ it 'raises NoSuchElementError when nothing matches' do
30
+ Raider.timeout = 0
31
+ expect { driver.find_element(label: 'Email') }
32
+ .to raise_error(Selenium::WebDriver::Error::NoSuchElementError, /no such element: \{ label: "Email" \}/)
33
+ end
34
+
35
+ it 'goes back to find_element_by when strict is off' do
36
+ Raider.strict = false
37
+ bridge.enqueue(['a'])
38
+ driver.find_element(label: 'Email')
39
+ expect(bridge.calls.map(&:first)).to eq([:find_element_by])
40
+ end
41
+
42
+ it 'passes the element ref through for an element scope' do
43
+ bridge.enqueue(['a'])
44
+ Selenium::WebDriver::Element.new(bridge, 'root').find_element(label: 'Email')
45
+ expect(bridge.calls.last).to end_with([:element, 'root'])
46
+ expect(bridge.calls.last.first).to eq(:find_elements_by)
47
+ end
48
+
49
+ it 'makes driver.first strict' do
50
+ bridge.enqueue(%w[a b])
51
+ expect { driver.first(label: 'Email') }.to raise_error(Raider::AmbiguousMatch)
52
+ expect(bridge.calls.map(&:first)).to eq([:find_elements_by])
53
+ end
54
+
55
+ it 'makes element.first strict' do
56
+ bridge.enqueue(%w[a b])
57
+ expect { Selenium::WebDriver::Element.new(bridge, 'root').first(label: 'Email') }.to raise_error(Raider::AmbiguousMatch)
58
+ expect(bridge.calls.map(&:first)).to eq([:find_elements_by])
59
+ end
60
+
61
+ it 'sends driver.first back to find_element_by when strict is off' do
62
+ Raider.strict = false
63
+ bridge.enqueue(['a'])
64
+ driver.first(label: 'Email')
65
+ expect(bridge.calls.map(&:first)).to eq([:find_element_by])
66
+ end
67
+
68
+ it 'returns find_elements results that already match without waiting' do
69
+ bridge.enqueue(%w[a b])
70
+ expect(driver.find_elements(label: 'Email').size).to eq(2)
71
+ end
72
+
73
+ describe 'waiting for a match' do
74
+ def find_calls(command = :find_elements_by)
75
+ bridge.calls.count { |call| call.first == command }
76
+ end
77
+
78
+ it 'returns the element once it shows up, after three calls' do
79
+ bridge.enqueue([])
80
+ bridge.enqueue([])
81
+ bridge.enqueue(['a'])
82
+ expect(driver.find_element(id: 'x')).to be_a(Selenium::WebDriver::Element)
83
+ expect(find_calls).to eq(3)
84
+ end
85
+
86
+ it 'raises NoSuchElementError naming the wait when nothing ever matches' do
87
+ Raider.timeout = 0.3
88
+ expect { driver.find_element(id: 'x') }
89
+ .to raise_error(Selenium::WebDriver::Error::NoSuchElementError, /no such element: \{ id: "x" \} \(waited 0\.[34]\d?s\)/)
90
+ end
91
+
92
+ it 'waits the same way when strict is off' do
93
+ Raider.strict = false
94
+ Raider.timeout = 0.3
95
+ expect { driver.find_element(id: 'x') }
96
+ .to raise_error(Selenium::WebDriver::Error::NoSuchElementError, /\(waited 0\.[34]\d?s\)/)
97
+ expect(find_calls(:find_element_by)).to be >= 3
98
+ end
99
+
100
+ it 'raises AmbiguousMatch on the first call' do
101
+ bridge.enqueue(%w[a b])
102
+ expect { driver.find_element(id: 'x') }.to raise_error(Raider::AmbiguousMatch)
103
+ expect(find_calls).to eq(1)
104
+ end
105
+
106
+ it 'returns the elements of find_elements once they show up, after three calls' do
107
+ bridge.enqueue([])
108
+ bridge.enqueue([])
109
+ bridge.enqueue(%w[a b])
110
+ expect(driver.find_elements(css: '.row').size).to eq(2)
111
+ expect(find_calls).to eq(3)
112
+ end
113
+
114
+ it 'returns [] from find_elements after about the timeout' do
115
+ Raider.timeout = 0.3
116
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
117
+ expect(driver.find_elements(css: '.row')).to eq([])
118
+ expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be_between(0.25, 0.6)
119
+ end
120
+
121
+ it 'makes one call per find inside Wait.immediately' do
122
+ Raider.timeout = 5
123
+ Raider::Wait.immediately do
124
+ expect(driver.find_elements(css: '.row')).to eq([])
125
+ expect { driver.find_element(id: 'x') }.to raise_error(Selenium::WebDriver::Error::NoSuchElementError)
126
+ end
127
+ expect(bridge.calls.map(&:first)).to eq(%i[find_elements_by find_elements_by])
128
+ end
129
+
130
+ it 'makes one call per find on the native path inside Wait.immediately' do
131
+ Raider.strict = false
132
+ Raider::Wait.immediately do
133
+ expect { driver.find_element(id: 'x') }.to raise_error(Selenium::WebDriver::Error::NoSuchElementError)
134
+ end
135
+ expect(bridge.calls.map(&:first)).to eq([:find_element_by])
136
+ end
137
+ end
138
+
139
+ context 'with an Appium bridge' do
140
+ let(:context_name) { 'NATIVE_APP' }
141
+ let(:bridge) do
142
+ name = context_name
143
+ klass = stub_const('Appium::Core::Base::Bridge', Class.new(Raider::Spec::FakeBridge) do
144
+ class << self; attr_accessor :locator_converter; end
145
+ define_method(:current_context) { name }
146
+ end)
147
+ klass.locator_converter = Selenium::WebDriver::Remote::Bridge::LocatorConverter.new
148
+ klass.new
149
+ end
150
+
151
+ it 'does not apply strictness on NATIVE_APP' do
152
+ bridge.enqueue(%w[a b])
153
+ driver.find_element(id: 'x')
154
+ expect(bridge.calls.map(&:first)).to eq([:find_element_by])
155
+ end
156
+
157
+ it 'waits for a match on NATIVE_APP too' do
158
+ Raider.timeout = 0.3
159
+ bridge.enqueue([])
160
+ bridge.enqueue(['a'])
161
+ expect(driver.find_element(id: 'x')).to be_a(Selenium::WebDriver::Element)
162
+ expect { driver.find_element(id: 'gone') }
163
+ .to raise_error(Selenium::WebDriver::Error::NoSuchElementError, /\(waited 0\.[34]\d?s\)/)
164
+ expect(driver.find_elements(id: 'gone')).to eq([])
165
+ end
166
+
167
+ it 'reports the elapsed wait, not the timeout, inside a shorter enclosing wait' do
168
+ Raider.timeout = 5
169
+ expect { Raider::Wait.until(timeout: 0.2) { driver.find_element(id: 'x') } }
170
+ .to raise_error(Selenium::WebDriver::Error::NoSuchElementError, /\(waited 0\.[23]\d?s\)/)
171
+ end
172
+
173
+ it 'raises NotSupported at once for a role find on NATIVE_APP' do
174
+ Raider.timeout = 5
175
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
176
+ expect { driver.find_element(role: 'button') }.to raise_error(Raider::NotSupported)
177
+ expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 1
178
+ end
179
+
180
+ context 'in a webview' do
181
+ let(:context_name) { 'WEBVIEW_1' }
182
+
183
+ it 'applies strictness' do
184
+ bridge.enqueue(%w[a b])
185
+ expect { driver.find_element(id: 'x') }.to raise_error(Raider::AmbiguousMatch)
186
+ end
187
+
188
+ it 'asks for the context once on a strict role find' do
189
+ bridge.script_results << ['a']
190
+ allow(bridge).to receive(:current_context).and_call_original
191
+ driver.find_element(role: 'button')
192
+ expect(bridge).to have_received(:current_context).once
193
+ end
194
+
195
+ it 'asks for the context once on a strict role find that waits several polls' do
196
+ bridge.script_results.push([], [], ['a'])
197
+ allow(bridge).to receive(:current_context).and_call_original
198
+ driver.find_element(role: 'button')
199
+ expect(bridge).to have_received(:current_context).once
200
+ end
201
+
202
+ it 'asks for the context once on a non-strict role find that waits several polls' do
203
+ Raider.strict = false
204
+ bridge.script_results.push([], [], ['a'])
205
+ allow(bridge).to receive(:current_context).and_call_original
206
+ driver.find_element(role: 'button')
207
+ expect(bridge).to have_received(:current_context).once
208
+ end
209
+
210
+ it 'asks for the context once on a role find_elements that waits several polls' do
211
+ bridge.script_results.push([], [], ['a'])
212
+ allow(bridge).to receive(:current_context).and_call_original
213
+ expect(driver.find_elements(role: 'button').size).to eq(1)
214
+ expect(bridge).to have_received(:current_context).once
215
+ end
216
+
217
+ it 'asks for the context once on a strict css find' do
218
+ bridge.enqueue(['a'])
219
+ allow(bridge).to receive(:current_context).and_call_original
220
+ driver.find_element(css: 'x')
221
+ expect(bridge).to have_received(:current_context).once
222
+ end
223
+ end
224
+ end
225
+
226
+ describe Raider::Locator do
227
+ it 'describes a role with options as by_role' do
228
+ expect(described_class.describe([{ role: ['button', { name: 'Save' }] }])).to eq("by_role('button', name: \"Save\")")
229
+ end
230
+
231
+ it 'shows the heading level' do
232
+ expect(described_class.describe([Raider.by_role('heading', level: 2)])).to eq("by_role('heading', level: 2)")
233
+ end
234
+
235
+ it 'describes a role without options' do
236
+ expect(described_class.describe([{ role: ['button', {}] }])).to eq("by_role('button')")
237
+ end
238
+
239
+ it 'describes a plain key' do
240
+ expect(described_class.describe([{ label: 'Email' }])).to eq('{ label: "Email" }')
241
+ end
242
+
243
+ it 'shows exact when set' do
244
+ expect(described_class.describe([{ label: ['Email', { exact: true }] }])).to eq('{ label: "Email", exact: true }')
245
+ end
246
+
247
+ it 'describes the two-argument form' do
248
+ expect(described_class.describe(['css selector', '.a'])).to eq('{ "css selector": ".a" }')
249
+ end
250
+
251
+ it 'never raises on odd shapes' do
252
+ [[], [nil], [{ role: [] }], [{ role: 5 }], [1, 2, 3]].each do |args|
253
+ expect(described_class.describe(args)).to be_a(String)
254
+ end
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'selenium-webdriver'
4
+
5
+ module Raider
6
+ module Spec
7
+ # One headless Chrome per process, created after the runtime is required.
8
+ module Browser
9
+ FIXTURES_DIR = File.expand_path('../fixtures', __dir__)
10
+
11
+ module_function
12
+
13
+ def driver
14
+ @driver ||= Selenium::WebDriver.for(:chrome, options: options)
15
+ end
16
+
17
+ def fixture_url(fixture)
18
+ "file://#{File.expand_path(fixture, FIXTURES_DIR)}"
19
+ end
20
+
21
+ def open(fixture)
22
+ driver.navigate.to(fixture_url(fixture))
23
+ end
24
+
25
+ def bidi_driver
26
+ @bidi_driver ||= Selenium::WebDriver.for(:chrome, options: options.tap { |chrome| chrome.web_socket_url = true })
27
+ end
28
+
29
+ def quit_bidi
30
+ @bidi_driver&.quit
31
+ @bidi_driver = nil
32
+ end
33
+
34
+ def quit
35
+ @driver&.quit
36
+ @driver = nil
37
+ end
38
+
39
+ def options
40
+ Selenium::WebDriver::Chrome::Options.new.tap do |chrome|
41
+ %w[--headless=new --no-sandbox --disable-dev-shm-usage --window-size=1280,900].each do |flag|
42
+ chrome.add_argument(flag)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'selenium-webdriver'
4
+
5
+ module Raider
6
+ module Spec
7
+ # A stand-in for Selenium's remote bridge: converts locators the way a real
8
+ # bridge does, records what reaches the wire and answers from a script.
9
+ # :reek:TooManyInstanceVariables { max_instance_variables: 6 } -- a test double: one slot per wire recorder and answer channel
10
+ class FakeBridge
11
+ attr_reader :calls, :script_results, :script_calls
12
+
13
+ def initialize
14
+ @locator_converter = Selenium::WebDriver::Remote::Bridge.locator_converter
15
+ @calls = []
16
+ @queue = []
17
+ @script_results = []
18
+ @script_calls = []
19
+ @answers = Hash.new { |hash, key| hash[key] = [] }
20
+ end
21
+
22
+ # Scripts per-poll answers for an element command; the last answer repeats.
23
+ # An Exception class or instance is raised instead of returned.
24
+ def answer(command, *values)
25
+ @answers[command].concat(values)
26
+ self
27
+ end
28
+
29
+ %i[element_text element_attribute element_displayed? element_enabled? element_selected?].each do |command|
30
+ define_method(command) { |*_args| next_answer(command) }
31
+ end
32
+
33
+ def enqueue(ids)
34
+ @queue << ids
35
+ end
36
+
37
+ def find_element_by(how, what, ref = [])
38
+ how, what = @locator_converter.convert(how, what)
39
+ calls << [:find_element_by, how, what, ref]
40
+ ids = @queue.shift || []
41
+ raise Selenium::WebDriver::Error::NoSuchElementError, 'no such element' if ids.empty?
42
+
43
+ wrap(ids.first)
44
+ end
45
+
46
+ def find_elements_by(how, what, ref = [])
47
+ how, what = @locator_converter.convert(how, what)
48
+ calls << [:find_elements_by, how, what, ref]
49
+ (@queue.shift || []).map { |id| wrap(id) }
50
+ end
51
+
52
+ def execute_script(script, *args)
53
+ script_calls << [script, *args]
54
+ result = script_results.shift
55
+ raise result if result.is_a?(Exception)
56
+
57
+ result
58
+ end
59
+
60
+ def click_element(id)
61
+ calls << [:click_element, id]
62
+ end
63
+
64
+ def send_keys_to_element(id, keys)
65
+ calls << [:send_keys_to_element, id, keys]
66
+ end
67
+
68
+ def clear_element(id)
69
+ calls << [:clear_element, id]
70
+ end
71
+
72
+ def submit_element(id)
73
+ calls << [:submit_element, id]
74
+ end
75
+
76
+ # Records the call; answers from the scripted channel, else raises so
77
+ # Describe.element falls back to <element>.
78
+ def element_tag_name(ref)
79
+ calls << [:element_tag_name, ref]
80
+ raise Selenium::WebDriver::Error::WebDriverError, 'no tag' if @answers[:element_tag_name].empty?
81
+
82
+ next_answer(:element_tag_name)
83
+ end
84
+
85
+ def element_class
86
+ Selenium::WebDriver::Element
87
+ end
88
+
89
+ private
90
+
91
+ def next_answer(command)
92
+ queue = @answers[command]
93
+ value = queue.size > 1 ? queue.shift : queue.first
94
+ raise value, 'scripted' if value.is_a?(Class) && value <= Exception
95
+
96
+ value
97
+ end
98
+
99
+ def wrap(id)
100
+ Selenium::WebDriver::Element.new(self, id)
101
+ end
102
+ end
103
+
104
+ # An initialized real bridge of klass, built without a network call: the
105
+ # initializer signature differs across selenium versions.
106
+ def self.real_bridge(klass)
107
+ keywords = Selenium::WebDriver::Remote::Bridge.instance_method(:initialize).parameters.map(&:last)
108
+ args = { http_client: Selenium::WebDriver::Remote::Http::Default.new }
109
+ args[:url] = 'http://127.0.0.1:4444/' if keywords.include?(:url)
110
+ klass.allocate.tap { |bridge| bridge.send(:initialize, **args) }
111
+ end
112
+
113
+ def self.driver(bridge)
114
+ Selenium::WebDriver::Driver.allocate.tap { |driver| driver.instance_variable_set(:@bridge, bridge) }
115
+ end
116
+ end
117
+ end