capybara_test_helpers 1.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.
@@ -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