capybara_test_helpers 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'capybara/rspec'
4
+ require 'active_support/core_ext/array/wrap'
5
+
6
+ # Internal: Avoid warnings in assert_valid_keys for passing the `test_helper` option.
7
+ Capybara::Queries::BaseQuery.prepend(Module.new {
8
+ attr_reader :test_helper
9
+
10
+ def initialize(options)
11
+ @test_helper = options.delete(:test_helper)
12
+ end
13
+ })
14
+
15
+ # Internal: Handle locator aliases provided in the test helper to finders,
16
+ # matchers, assertions, and actions.
17
+ Capybara::Queries::SelectorQuery.prepend(Module.new {
18
+ def initialize(*args, **options, &filter_block)
19
+ # Resolve any locator aliases defined in the test helper where this query
20
+ # originated from (through a finder, assertion, or matcher).
21
+ if test_helper = options[:test_helper]
22
+ args, options, filter_block = test_helper.resolve_alias_for_selector_query(args, options, filter_block)
23
+ end
24
+
25
+ # Unwrap any test helpers that were provided to the :label selector, since
26
+ # it's making an explicit check by class.
27
+ options[:for] = options[:for].to_capybara_node if options[:for].is_a?(CapybaraTestHelpers::TestHelper)
28
+
29
+ super(*args, **options, &filter_block)
30
+ end
31
+ })
32
+
33
+ # Public: Adds aliasing for element selectors to make it easier to encapsulate
34
+ # how to find a particular kind of element in the UI.
35
+ module CapybaraTestHelpers::Selectors
36
+ SELECTOR_SEPARATOR = ','
37
+
38
+ def self.included(base)
39
+ base.extend(ClassMethods)
40
+ end
41
+
42
+ module ClassMethods
43
+ # Public: Returns the available selectors for the test helper, or an empty
44
+ # Hash if selectors are not defined.
45
+ def selectors
46
+ unless defined?(@selectors)
47
+ parent_selectors = superclass.respond_to?(:selectors) ? superclass.selectors : {}
48
+ child_selectors = (defined?(self::SELECTORS) && self::SELECTORS || {})
49
+ .tap { |new_selectors| validate_selectors(new_selectors) }
50
+ @selectors = parent_selectors.merge(child_selectors).transform_values(&:freeze).freeze
51
+ end
52
+ @selectors
53
+ end
54
+
55
+ # Internal: Allows to "call" selectors, as a shortcut for find.
56
+ #
57
+ # Example: table.header == table.find(:header)
58
+ def define_getters_for_selectors
59
+ selectors.each_key do |selector_name|
60
+ define_method(selector_name) { |*args, **kwargs, &block|
61
+ find(selector_name, *args, **kwargs, &block)
62
+ }
63
+ end
64
+ end
65
+
66
+ # Internal: Validates that all the selectors defined in the class won't
67
+ # cause confusion or misbehavior.
68
+ def validate_selectors(selectors)
69
+ selectors.each_key do |name|
70
+ if Capybara::Selector.all.key?(name)
71
+ raise "A selector with the name #{ name.inspect } is already registered in Capybara," \
72
+ " consider renaming the #{ name.inspect } alias in #{ self.class.name } to avoid confusion."
73
+ end
74
+ if CapybaraTestHelpers::RESERVED_METHODS.include?(name)
75
+ raise "A method with the name #{ name.inspect } is part of the Capybara DSL," \
76
+ " consider renaming the #{ name.inspect } alias in #{ self.class.name } to avoid confusion."
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ # Public: Returns the available selectors for the test helper, or an empty
83
+ # Hash if selectors are not defined.
84
+ def selectors
85
+ self.class.selectors
86
+ end
87
+
88
+ # Internal: Inspects the arguments for a SelectorQuery, and resolves a
89
+ # selector alias if provided.
90
+ #
91
+ # Returns a pair of arguments and keywords to initialize a SelectorQuery.
92
+ def resolve_alias_for_selector_query(args, kwargs, filter_block)
93
+ # Extract the explicitly provided selector, if any. Example: `find_button`.
94
+ explicit_type = args.shift if selectors.key?(args[1])
95
+
96
+ if selectors.key?(args[0])
97
+ args, kwargs = locator_for(*args, **kwargs)
98
+
99
+ # Remove the type provided by the alias, and add back the explicit one.
100
+ if explicit_type
101
+ args.shift if args[0].is_a?(Symbol)
102
+ args.unshift(explicit_type)
103
+ end
104
+ end
105
+
106
+ [args, kwargs, wrap_filter(filter_block)]
107
+ end
108
+
109
+ private
110
+
111
+ # Internal: Checks if there's a Capybara selector defined by that name.
112
+ def registered_selector?(name)
113
+ Capybara::Selector.all.key?(name)
114
+ end
115
+
116
+ # Internal: Returns the query locator defined under the specified alias.
117
+ #
118
+ # NOTE: In most cases, the query locator is a simple CSS selector, but it also
119
+ # supports any of the built-in Capybara selectors.
120
+ #
121
+ # Returns an Array with the Capybara locator arguments, and options if any.
122
+ def locator_for(*args, **kwargs)
123
+ if args.size == 1
124
+ args = [*Array.wrap(resolve_locator_alias(args.first))]
125
+ kwargs = args.pop.deep_merge(kwargs) if args.last.is_a?(Hash)
126
+ end
127
+ [args, kwargs]
128
+ rescue KeyError => error
129
+ raise NotImplementedError, "A selector in #{ self.class.name } is not defined, #{ error.message }"
130
+ end
131
+
132
+ # Internal: Resolves one of the segments of a locator alias.
133
+ def resolve_locator_alias(fragment)
134
+ return fragment unless fragment.is_a?(Symbol) && (selectors.key?(fragment) || !registered_selector?(fragment))
135
+
136
+ locator = selectors.fetch(fragment)
137
+
138
+ locator.is_a?(Array) ? combine_locator_fragments(locator) : locator
139
+ end
140
+
141
+ # Internal: Resolves a complex locator alias, which might reference other
142
+ # locator aliases as well.
143
+ def combine_locator_fragments(fragments)
144
+ return fragments unless fragments.any? { |fragment| fragment.is_a?(Symbol) }
145
+
146
+ fragments = fragments.map { |fragment| resolve_locator_alias(fragment) }
147
+ flat_fragments = fragments.flatten(1)
148
+ type = flat_fragments.shift if flat_fragments.first.is_a?(Symbol)
149
+
150
+ # Only flatten fragments if it's CSS or XPath
151
+ if type.nil? || type == :css || type == :xpath
152
+ fragments = flat_fragments
153
+ else
154
+ type = nil
155
+ end
156
+
157
+ options = fragments.pop if fragments.last.is_a?(Hash)
158
+
159
+ [type, *combine_css_selectors(fragments), options].compact
160
+ end
161
+
162
+ # Internal: Combines parent and child classes to preserve the order.
163
+ def combine_css_selectors(selectors)
164
+ return selectors unless selectors.size > 1 && selectors.all? { |selector| selector.is_a?(String) }
165
+
166
+ selectors.reduce { |parent_selectors, children_selectors|
167
+ parent_selectors.split(SELECTOR_SEPARATOR).flat_map { |parent_selector|
168
+ children_selectors.split(SELECTOR_SEPARATOR).map { |children_selector|
169
+ "#{ parent_selector }#{ children_selector }"
170
+ }
171
+ }.join(SELECTOR_SEPARATOR)
172
+ }
173
+ end
174
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Internal: Provides helper functions to perform asynchronous assertions.
4
+ module CapybaraTestHelpers::Synchronization
5
+ # Internal: Necessary because the RSpec exception is not a StandardError and
6
+ # thus capybara does not rescue it, so it wouldn't attempt to retry it.
7
+ class ExpectationError < StandardError; end
8
+
9
+ # Internal: Errors that will be retried in a `synchronize_expectation` block.
10
+ EXPECTATION_ERRORS = [
11
+ ExpectationError,
12
+ Capybara::ElementNotFound,
13
+ (Selenium::WebDriver::Error::StaleElementReferenceError if defined?(Selenium::WebDriver::Error::StaleElementReferenceError)),
14
+ ].compact.freeze
15
+
16
+ protected
17
+
18
+ # Public: Can be used to make an asynchronous expectation, that will be
19
+ # retried until the max wait time configured in Capybara.
20
+ def synchronize_expectation(retry_on_errors: [], **options)
21
+ synchronize(errors: EXPECTATION_ERRORS + retry_on_errors, **options) {
22
+ begin
23
+ yield
24
+ rescue RSpec::Expectations::ExpectationNotMetError => error
25
+ # NOTE: Rethrow as ExpectationError because the RSpec exception is not
26
+ # a StandardError so capybara wouldn't rescue it inside synchronize.
27
+ raise ExpectationError, error
28
+ end
29
+ }
30
+ rescue ExpectationError => error
31
+ raise error.cause # Unwrap this internal error and raise the original error.
32
+ end
33
+
34
+ # Public: Used to implement more specific synchronization helpers.
35
+ #
36
+ # By default Capybara's methods like `find` and `have_css` already use
37
+ # synchronize to achieve asynchronicity, so it's not necessary to use this.
38
+ def synchronize(wait: Capybara.default_max_wait_time == 0 ? Capybara.default_max_wait_time : 3, **options, &block)
39
+ (current_element? ? current_context : page.document).synchronize(wait, **options, &block)
40
+ end
41
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'capybara/rspec'
4
+
5
+ require 'active_support/core_ext/module/delegation'
6
+ require 'active_support/core_ext/string/inflections'
7
+ require 'active_support/core_ext/object/blank'
8
+
9
+ require 'capybara_test_helpers/selectors'
10
+ require 'capybara_test_helpers/synchronization'
11
+ require 'capybara_test_helpers/finders'
12
+ require 'capybara_test_helpers/actions'
13
+ require 'capybara_test_helpers/assertions'
14
+ require 'capybara_test_helpers/matchers'
15
+
16
+ # Public: Base class to create test helpers that have full access to the
17
+ # Capybara DSL, while easily defining custom assertions, getters, and actions.
18
+ #
19
+ # It also supports locator aliases to prevent duplication and keep tests easier
20
+ # to understand and to maintain.
21
+ class CapybaraTestHelpers::TestHelper
22
+ include RSpec::Matchers
23
+ include RSpec::Mocks::ExampleMethods
24
+ include Capybara::DSL
25
+ include CapybaraTestHelpers::Selectors
26
+ include CapybaraTestHelpers::Synchronization
27
+ include CapybaraTestHelpers::Finders
28
+ include CapybaraTestHelpers::Actions
29
+ include CapybaraTestHelpers::Assertions
30
+ include CapybaraTestHelpers::Matchers
31
+
32
+ undef_method(*CapybaraTestHelpers::SKIPPED_DSL_METHODS)
33
+
34
+ attr_reader :query_context, :test_context
35
+
36
+ def initialize(query_context, test_context: query_context, negated: nil)
37
+ @query_context, @test_context, @negated = query_context, test_context, negated
38
+ end
39
+
40
+ # Public: To make the benchmark log less verbose.
41
+ def inspect
42
+ %(#<#{ self.class.name } #{ current_element? ? %(tag="#{ base.tag_name }") : object_id }>)
43
+ rescue *page.driver.invalid_element_errors
44
+ %(#<#{ self.class.name } #{ object_id }>)
45
+ end
46
+
47
+ # Public: Makes it easier to inspect the current element.
48
+ def inspect_node
49
+ to_capybara_node.inspect
50
+ end
51
+
52
+ # Public: Casts the current context as a Capybara::Node::Element.
53
+ #
54
+ # NOTE: Uses the :el convention, which means actions can be performed directly
55
+ # on the test helper if an :el selector is defined.
56
+ def to_capybara_node
57
+ return current_context if current_element?
58
+ return find_element(:el) if selectors.key?(:el)
59
+
60
+ raise_missing_element_error
61
+ end
62
+
63
+ # Public: Wraps a Capybara::Node::Element with a test helper object.
64
+ def wrap_element(capybara_node)
65
+ if capybara_node.is_a?(Enumerable)
66
+ capybara_node.map { |node| wrap_element(node) }
67
+ else
68
+ self.class.new(capybara_node, test_context: test_context)
69
+ end
70
+ end
71
+
72
+ # Public: Wraps a CapybaraTestHelper with a different test helper object.
73
+ def wrap_test_helper(test_helper)
74
+ self.class.new(test_helper.query_context, test_context: test_context) if test_helper.query_context
75
+ end
76
+
77
+ # Public: Scopes the Capybara queries in the block to be inside the specified
78
+ # selector.
79
+ def within_element(*args, **kwargs, &block)
80
+ locator = args.empty? ? [self] : args
81
+ kwargs[:test_helper] = self
82
+ page.within(*locator, **kwargs, &block)
83
+ end
84
+
85
+ # Public: Scopes the Capybara queries in the block to be inside the specified
86
+ # selector.
87
+ def within(*args, **kwargs, &block)
88
+ return be_within(*args, **kwargs) unless block_given? # RSpec matcher.
89
+
90
+ within_element(*args, **kwargs, &block)
91
+ end
92
+
93
+ # Public: Unscopes the inner block from any previous `within` calls.
94
+ def within_document
95
+ page.instance_exec { scopes << nil }
96
+ yield wrap_element(page.document)
97
+ ensure
98
+ page.instance_exec { scopes.pop }
99
+ end
100
+
101
+ # Internal: Returns the name of the class without the suffix.
102
+ #
103
+ # Example: 'current_page' for CurrentPageTestHelper.
104
+ def friendly_name
105
+ self.class.name.chomp('TestHelper').underscore
106
+ end
107
+
108
+ protected
109
+
110
+ # Internal: Used to perform assertions and others.
111
+ def current_context
112
+ query_context.respond_to?(:to_capybara_node) ? query_context : page
113
+ end
114
+
115
+ # Internal: Returns true if the current context is an element.
116
+ def current_element?
117
+ current_context.is_a?(Capybara::Node::Element)
118
+ end
119
+
120
+ private
121
+
122
+ # Internal: Wraps the optional filter block to ensure we pass it a test helper
123
+ # instead of a raw Capybara::Node::Element.
124
+ def wrap_filter(filter)
125
+ proc { |capybara_node_element| filter.call(wrap_element(capybara_node_element)) } if filter
126
+ end
127
+
128
+ # Internal: Helper to provide more information on the error.
129
+ def raise_missing_element_error
130
+ method_caller = caller.select { |x| x['test_helpers'] }[1]
131
+ method_caller_name = method_caller&.match(/in `(\w+)'/)
132
+ method_caller_name = method_caller_name ? method_caller_name[1] : method_caller
133
+ raise ArgumentError, "You are calling the `#{ method_caller_name }' method on the test helper but :el is not defined nor there's a current element.\n"\
134
+ 'Define :el, or find an element before performing the action.'
135
+ end
136
+
137
+ class << self
138
+ # Public: Make methods in the test context available in the helpers.
139
+ def delegate_to_test_context(*method_names)
140
+ delegate(*method_names, to: :test_context)
141
+ end
142
+
143
+ # Public: Allows to define dependencies on other matchers.
144
+ #
145
+ # NOTE: When you call a helper the "negated" state is preserved for assertions.
146
+ #
147
+ # NOTE: You can also pass an element to a test helper to "wrap" a specified
148
+ # element with the specified test helper class.
149
+ #
150
+ # Example:
151
+ # dropdown(element).toggle_menu
152
+ def use_test_helpers(*helper_names)
153
+ helper_names.each do |helper_name|
154
+ private define_method(helper_name) { |element = nil|
155
+ test_helper = test_context.get_test_helper(helper_name)
156
+ if element
157
+ raise ArgumentError, "#{ element.inspect } must be a test helper or element." unless element.respond_to?(:to_capybara_node)
158
+
159
+ test_helper = test_helper.wrap_element(element.to_capybara_node)
160
+ end
161
+ @negated.nil? ? test_helper : test_helper.should(@negated)
162
+ }
163
+ end
164
+ end
165
+
166
+ # Internal: Allows to perform certain actions just before a test helper will
167
+ # be loaded for the first time.
168
+ def on_test_helper_load
169
+ define_getters_for_selectors
170
+ end
171
+
172
+ # Internal: Fail early if a reserved method is redefined.
173
+ def method_added(method_name)
174
+ return unless CapybaraTestHelpers::RESERVED_METHODS.include?(method_name)
175
+
176
+ raise "A method with the name #{ method_name.inspect } is part of the Capybara DSL," \
177
+ ' overriding it could cause unexpected issues that could be very hard to debug.'
178
+ end
179
+ end
180
+ end
181
+
182
+ Capybara::TestHelper = CapybaraTestHelpers::TestHelper unless defined?(Capybara::TestHelper)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'capybara/rspec'
4
+
5
+ # Internal: Used heavily in the RSpec matchers, makes it very easy to create
6
+ # a dual assertion (can be used as positive or negative).
7
+ #
8
+ # See https://maximomussini.com/posts/cucumber-to_or_not_to/
9
+ module CapybaraTestHelpers::ToOrExpectationHandler
10
+ # Public: Allows a more convenient definition of should/should not Gherkin steps.
11
+ #
12
+ # Example:
13
+ #
14
+ # Then(/^I should (not )?see "(.*)"$/) do |not_to, text|
15
+ # expect(page).to_or not_to, have_content(text)
16
+ # end
17
+ #
18
+ def to_or(not_to, matcher, message = nil, &block)
19
+ if not_to
20
+ not_to(matcher, message, &block)
21
+ else
22
+ to(matcher, message, &block)
23
+ end
24
+ end
25
+ end
26
+
27
+ RSpec::Expectations::ExpectationTarget.include(CapybaraTestHelpers::ToOrExpectationHandler)
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Easily write fluent Page Objects for Capybara in Ruby.
4
+ module CapybaraTestHelpers
5
+ VERSION = '1.0.0'
6
+ end
@@ -0,0 +1,26 @@
1
+ require 'capybara_test_helpers'
2
+ require 'rails/generators/named_base'
3
+
4
+ # Internal: Generates a new test helper file in the appropriate directory.
5
+ class TestHelperGenerator < Rails::Generators::NamedBase
6
+ def create_helper_file
7
+ create_file("#{ CapybaraTestHelpers.config.helpers_paths.first }/#{ file_name }_test_helper.rb") {
8
+ <<~CAPYBARA_TEST_HELPER
9
+ # frozen_string_literal: true
10
+
11
+ class #{ file_name.camelize }TestHelper < #{ file_name.to_s == 'base' ? 'Capybara::TestHelper' : 'BaseTestHelper' }
12
+ # Selectors: Semantic aliases for elements, a useful abstraction.
13
+ SELECTORS = {}
14
+
15
+ # Getters: A convenient way to get related data or nested elements.
16
+
17
+ # Actions: Encapsulate complex actions to provide a cleaner interface.
18
+
19
+ # Assertions: Check on element properties, used with `should` and `should_not`.
20
+
21
+ # Background: Helpers to add/modify/delete data in the database or session.
22
+ end
23
+ CAPYBARA_TEST_HELPER
24
+ }
25
+ end
26
+ end