capybara-compose 1.0.4

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,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+ require 'active_support/core_ext/numeric/time'
5
+ require 'rainbow'
6
+
7
+ # rubocop:disable Style/ClassVars
8
+
9
+ # Public: Keeps track of the running time for user-defined helpers, useful as a
10
+ # way to keep track of the executed methods, and to easily spot slow operations.
11
+ module Capybara::Compose::BenchmarkHelpers
12
+ extend ActiveSupport::Concern
13
+
14
+ included do
15
+ @@indentation_level = 0
16
+ @@indented_logs = []
17
+ end
18
+
19
+ protected
20
+
21
+ # Internal: Helper to benchmark an operation, outputs the method name, its
22
+ # arguments, and the ellapsed time in milliseconds.
23
+ def benchmark_method(method_name, args, kwargs)
24
+ @@indented_logs.push(log = +'') # Push it in order, set the content later.
25
+ @@indentation_level += 1
26
+ before = Time.now
27
+ yield
28
+ ensure
29
+ diff_in_millis = (Time.now - before).in_milliseconds.round
30
+ @@indentation_level -= 1
31
+
32
+ # Set the queued message with the method call and the ellapsed time.
33
+ log.sub!('', _benchmark_str(method_name: method_name, args: args, kwargs: kwargs, time: diff_in_millis))
34
+
35
+ # Print the messages once we outdent all, and clear the queue.
36
+ @@indented_logs.each { |inner_log| Kernel.puts(inner_log) }.clear if @@indentation_level.zero?
37
+ end
38
+
39
+ private
40
+
41
+ # Internal: Indents nested method calls, and adds color to make it readable.
42
+ def _benchmark_str(method_name:, args:, kwargs:, time:)
43
+ args += [kwargs] unless kwargs.empty?
44
+ args_str = args.map(&:inspect)
45
+ [
46
+ ' ' * @@indentation_level,
47
+ Rainbow(self.class.name.chomp('TestHelper') + '#').slategray.rjust(40),
48
+ Rainbow(method_name.to_s).cyan,
49
+ Rainbow("(#{ args_str.join(', ') })").slategray,
50
+ ' ',
51
+ Rainbow("#{ time } ms").send(time > 1000 && :red || time > 100 && :yellow || :green),
52
+ ].join('')
53
+ end
54
+
55
+ module ClassMethods
56
+ # Hook: Benchmarks all methods in the class once it's loaded.
57
+ def on_test_helper_load
58
+ super
59
+ benchmark_all
60
+ end
61
+
62
+ # Debug: Wraps all instance methods of the test helper class to log them.
63
+ def benchmark_all
64
+ return if defined?(@benchmarked_all)
65
+
66
+ benchmark(instance_methods - superclass.instance_methods - [:lazy_for])
67
+ @benchmarked_all = true
68
+ end
69
+
70
+ # Debug: Wraps a method to output its parameters and ellapsed time.
71
+ #
72
+ # Usage:
73
+ # benchmark :input_for
74
+ # benchmark def input_for(...)
75
+ def benchmark(method_names)
76
+ prepend(Module.new {
77
+ Array.wrap(method_names).each do |method_name|
78
+ define_method(method_name) { |*args, **kwargs, &block|
79
+ benchmark_method(method_name, args, kwargs) { super(*args, **kwargs, &block) }
80
+ }
81
+ end
82
+ })
83
+ method_names
84
+ end
85
+ end
86
+ end
87
+ # rubocop:enable Style/ClassVars
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'capybara/compose'
4
+
5
+ World(Capybara::Compose::DependencyInjection)
6
+
7
+ # Public: Use outside of the steps to make it available on all steps.
8
+ def use_test_helpers(*names)
9
+ names.each do |name|
10
+ define_method(name) { get_test_helper(name) }
11
+ end
12
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Internal: Provides dependency injection for RSpec and Cucumber, by using the
4
+ # test helper name and a convention for naming and organizing helpers.
5
+ module Capybara::Compose::DependencyInjection
6
+ # Public: Returns an instance of a test helper that inherits BaseTestHelper.
7
+ #
8
+ # NOTE: Memoizes the test helper instances, keeping one per test helper class.
9
+ # Test helpers are not mutable, they return a new instance every time an
10
+ # operation is performed, so it's safe to apply this optimization.
11
+ def get_test_helper(helper_name)
12
+ ivar_name = "@#{ helper_name }_capybara_test_helper"
13
+ instance_variable_get(ivar_name) ||
14
+ instance_variable_set(ivar_name, get_test_helper_class(helper_name).new(self))
15
+ end
16
+
17
+ # Internal: Requires a test helper file and memoizes the class for all tests.
18
+ #
19
+ # Returns a Class that subclasses BaseTestHelper.
20
+ def get_test_helper_class(name)
21
+ file_name = "#{ name }_test_helper"
22
+ ivar_name = "@#{ file_name }_test_helper_class"
23
+ instance_variable_get(ivar_name) || begin
24
+ require_test_helper(file_name)
25
+ test_helper_class = file_name.camelize.constantize
26
+ test_helper_class.on_test_helper_load
27
+ instance_variable_set(ivar_name, test_helper_class)
28
+ end
29
+ end
30
+
31
+ # Internal: Requires a test helper file.
32
+ def require_test_helper(name)
33
+ Capybara::Compose.config.helpers_paths.each do |path|
34
+ require Pathname.new(File.expand_path(path)).join("#{ name }.rb").to_s
35
+ return true # Don't check on the result, it could have been required earlier.
36
+ rescue LoadError
37
+ false
38
+ end
39
+ raise LoadError, "No '#{ name }.rb' file found in #{ Capybara::Compose.config.helpers_paths.inspect }. "\
40
+ 'Check for typos, or make sure the dirs in `Capybara::Compose.config.helpers_paths` are in the load path.'
41
+ end
42
+ end
43
+
44
+ Capybara.extend(Capybara::Compose::DependencyInjection)
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Internal: Wraps Capybara finders to be aware of the selector aliases, and to
4
+ # auto-wrap the returned elements with test helpers.
5
+ module Capybara::Compose::Finders
6
+ %i[
7
+ find
8
+ find_all
9
+ find_field
10
+ find_link
11
+ find_button
12
+ find_by_id
13
+ first
14
+ ancestor
15
+ sibling
16
+ ].each do |method_name|
17
+ Capybara::Compose.define_helper_method(self, method_name, wrap: true)
18
+ end
19
+
20
+ # Public: Returns all the Capybara nodes that match the specified selector.
21
+ #
22
+ # Returns an Array of Capybara::Element that match the query.
23
+ def all(*args, **kwargs, &filter)
24
+ if defined?(::RSpec::Matchers::BuiltIn::All) && args.first.respond_to?(:matches?)
25
+ ::RSpec::Matchers::BuiltIn::All.new(*args, **kwargs)
26
+ else
27
+ find_all(*args, **kwargs, &filter)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ # Internal: Finds an element that matches the specified locator and options.
34
+ #
35
+ # Returns a Capybara::Node::Element that matches the conditions, or fails.
36
+ def find_element(*args, **kwargs, &filter)
37
+ kwargs[:test_helper] = self
38
+ current_context.find(*args, **kwargs, &filter)
39
+ end
40
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Internal: Wraps Capybara matchers to enable locator aliases, and to wrap the
4
+ # result with a test helper so that methods can be chained in a fluent style.
5
+ module Capybara::Compose::Matchers
6
+ %i[
7
+ has_selector?
8
+ has_no_selector?
9
+ has_css?
10
+ has_no_css?
11
+ has_xpath?
12
+ has_no_xpath?
13
+ has_link?
14
+ has_no_link?
15
+ has_button?
16
+ has_no_button?
17
+ has_field?
18
+ has_no_field?
19
+ has_select?
20
+ has_no_select?
21
+ has_table?
22
+ has_no_table?
23
+ has_checked_field?
24
+ has_no_checked_field?
25
+ has_unchecked_field?
26
+ has_no_unchecked_field?
27
+ has_title?
28
+ has_no_title?
29
+ has_title?
30
+ has_no_title?
31
+ ].each do |method_name|
32
+ Capybara::Compose.define_helper_method(self, method_name)
33
+ end
34
+
35
+ %i[
36
+ has_ancestor?
37
+ has_no_ancestor?
38
+ has_sibling?
39
+ has_no_sibling?
40
+ matches_selector?
41
+ not_matches_selector?
42
+ matches_css?
43
+ not_matches_css?
44
+ matches_xpath?
45
+ not_matches_xpath?
46
+ has_text?
47
+ has_no_text?
48
+ has_content?
49
+ has_no_content?
50
+ matches_style?
51
+ has_style?
52
+ ].each do |method_name|
53
+ Capybara::Compose.define_helper_method(self, method_name, target: :to_capybara_node)
54
+ end
55
+
56
+ %i[
57
+ assert_selector
58
+ assert_all_of_selectors
59
+ assert_none_of_selectors
60
+ assert_any_of_selectors
61
+ assert_no_selector
62
+ ].each do |method_name|
63
+ Capybara::Compose.define_helper_method(self, method_name)
64
+ end
65
+
66
+ %i[
67
+ assert_matches_selector
68
+ assert_not_matches_selector
69
+ assert_ancestor
70
+ assert_no_ancestor
71
+ assert_sibling
72
+ assert_no_sibling
73
+ ].each do |method_name|
74
+ Capybara::Compose.define_helper_method(self, method_name, target: :to_capybara_node)
75
+ end
76
+
77
+ alias has? has_selector?
78
+ alias has_no? has_no_selector?
79
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'capybara/compose'
4
+ require 'capybara/rspec'
5
+
6
+ # Public: Use in an RSpec describe block or in an included module to make
7
+ # helpers available on all specs.
8
+ def use_test_helpers(*names)
9
+ names.each do |name|
10
+ let(name) { get_test_helper(name) }
11
+ end
12
+ end
13
+
14
+ RSpec.configure do |config|
15
+ # Make it available only in certain types of tests.
16
+ types = %i[feature system view]
17
+
18
+ # Options that will register a test helper for the test to use.
19
+ keys = %i[capybara/compose test_helpers helpers]
20
+
21
+ # Inject test helpers by using a :helpers or :test_helpers option.
22
+ inject_test_helpers = proc { |example|
23
+ keys.flat_map { |key| example.metadata[key] }.compact.each do |name|
24
+ example.example_group_instance.define_singleton_method(name) { get_test_helper(name) }
25
+ end
26
+ }
27
+
28
+ # Allow injecting test helpers in a feature or scenario.
29
+ types.each do |type|
30
+ config.include(Capybara::Compose::DependencyInjection, type: type)
31
+ config.before(:each, type: type, &inject_test_helpers)
32
+ end
33
+ end
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/array/wrap'
4
+
5
+ # Internal: Avoid warnings in assert_valid_keys for passing the `test_helper` option.
6
+ Capybara::Queries::BaseQuery.prepend(Module.new {
7
+ attr_reader :test_helper
8
+
9
+ def initialize(options)
10
+ @test_helper = options.delete(:test_helper)
11
+ end
12
+ })
13
+
14
+ # Internal: Handle locator aliases provided in the test helper to finders,
15
+ # matchers, assertions, and actions.
16
+ Capybara::Queries::SelectorQuery.prepend(Module.new {
17
+ def initialize(*args, **options, &filter_block)
18
+ # Resolve any locator aliases defined in the test helper where this query
19
+ # originated from (through a finder, assertion, or matcher).
20
+ if test_helper = options[:test_helper]
21
+ args, options, filter_block = test_helper.resolve_alias_for_selector_query(args, options, filter_block)
22
+ end
23
+
24
+ # Unwrap any test helpers that were provided to the :label selector, since
25
+ # it's making an explicit check by class.
26
+ options[:for] = options[:for].to_capybara_node if options[:for].is_a?(Capybara::Compose::TestHelper)
27
+
28
+ super(*args, **options, &filter_block)
29
+ end
30
+ })
31
+
32
+ # Public: Adds aliasing for element selectors to make it easier to encapsulate
33
+ # how to find a particular kind of element in the UI.
34
+ module Capybara::Compose::Selectors
35
+ SELECTOR_SEPARATOR = ','
36
+
37
+ def self.included(base)
38
+ base.extend(ClassMethods)
39
+ end
40
+
41
+ module ClassMethods
42
+ # Public: Light wrapper as syntax sugar for defining SELECTORS.
43
+ def aliases(selectors = {})
44
+ const_set('SELECTORS', selectors)
45
+ end
46
+
47
+ # Public: Returns the available selectors for the test helper, or an empty
48
+ # Hash if selectors are not defined.
49
+ def selectors
50
+ unless defined?(@selectors)
51
+ parent_selectors = superclass.respond_to?(:selectors) ? superclass.selectors : {}
52
+ child_selectors = (defined?(self::SELECTORS) && self::SELECTORS || {})
53
+ .tap { |new_selectors| validate_selectors(new_selectors) }
54
+ @selectors = parent_selectors.merge(child_selectors).transform_values(&:freeze).freeze
55
+ end
56
+ @selectors
57
+ end
58
+
59
+ # Internal: Allows to "call" selectors, as a shortcut for find.
60
+ #
61
+ # Example: table.header == table.find(:header)
62
+ def define_getters_for_selectors
63
+ selectors.each_key do |selector_name|
64
+ define_method(selector_name) { |*args, **kwargs, &block|
65
+ find(selector_name, *args, **kwargs, &block)
66
+ }
67
+ end
68
+ end
69
+
70
+ # Internal: Validates that all the selectors defined in the class won't
71
+ # cause confusion or misbehavior.
72
+ def validate_selectors(selectors)
73
+ selectors.each_key do |name|
74
+ if Capybara::Selector.all.key?(name)
75
+ raise "A selector with the name #{ name.inspect } is already registered in Capybara," \
76
+ " consider renaming the #{ name.inspect } alias in #{ self.class.name } to avoid confusion."
77
+ end
78
+ if Capybara::Compose::RESERVED_METHODS.include?(name)
79
+ raise "A method with the name #{ name.inspect } is part of the Capybara DSL," \
80
+ " consider renaming the #{ name.inspect } alias in #{ self.class.name } to avoid confusion."
81
+ end
82
+ end
83
+ end
84
+ end
85
+
86
+ # Public: Returns the available selectors for the test helper, or an empty
87
+ # Hash if selectors are not defined.
88
+ def selectors
89
+ self.class.selectors
90
+ end
91
+
92
+ # Internal: Inspects the arguments for a SelectorQuery, and resolves a
93
+ # selector alias if provided.
94
+ #
95
+ # Returns a pair of arguments and keywords to initialize a SelectorQuery.
96
+ def resolve_alias_for_selector_query(args, kwargs, filter_block)
97
+ # Extract the explicitly provided selector, if any. Example: `find_button`.
98
+ explicit_type = args.shift if selectors.key?(args[1])
99
+
100
+ if selectors.key?(args[0])
101
+ args, kwargs = locator_for(*args, **kwargs)
102
+
103
+ # Remove the type provided by the alias, and add back the explicit one.
104
+ if explicit_type
105
+ args.shift if args[0].is_a?(Symbol)
106
+ args.unshift(explicit_type)
107
+ end
108
+ end
109
+
110
+ [args, kwargs, wrap_filter(filter_block)]
111
+ end
112
+
113
+ private
114
+
115
+ # Internal: Checks if there's a Capybara selector defined by that name.
116
+ def registered_selector?(name)
117
+ Capybara::Selector.all.key?(name)
118
+ end
119
+
120
+ # Internal: Returns the query locator defined under the specified alias.
121
+ #
122
+ # NOTE: In most cases, the query locator is a simple CSS selector, but it also
123
+ # supports any of the built-in Capybara selectors.
124
+ #
125
+ # Returns an Array with the Capybara locator arguments, and options if any.
126
+ def locator_for(*args, **kwargs)
127
+ if args.size == 1
128
+ args = [*Array.wrap(resolve_locator_alias(args.first))]
129
+ kwargs = args.pop.deep_merge(kwargs) if args.last.is_a?(Hash)
130
+ end
131
+ [args, kwargs]
132
+ rescue KeyError => error
133
+ raise NotImplementedError, "A selector in #{ self.class.name } is not defined, #{ error.message }"
134
+ end
135
+
136
+ # Internal: Resolves one of the segments of a locator alias.
137
+ def resolve_locator_alias(fragment)
138
+ return fragment unless fragment.is_a?(Symbol) && (selectors.key?(fragment) || !registered_selector?(fragment))
139
+
140
+ locator = selectors.fetch(fragment)
141
+
142
+ locator.is_a?(Array) ? combine_locator_fragments(locator) : locator
143
+ end
144
+
145
+ # Internal: Resolves a complex locator alias, which might reference other
146
+ # locator aliases as well.
147
+ def combine_locator_fragments(fragments)
148
+ return fragments unless fragments.any? { |fragment| fragment.is_a?(Symbol) }
149
+
150
+ fragments = fragments.map { |fragment| resolve_locator_alias(fragment) }
151
+ flat_fragments = fragments.flatten(1)
152
+ type = flat_fragments.shift if flat_fragments.first.is_a?(Symbol)
153
+
154
+ # Only flatten fragments if it's CSS or XPath
155
+ if type.nil? || type == :css || type == :xpath
156
+ fragments = flat_fragments
157
+ else
158
+ type = nil
159
+ end
160
+
161
+ options = fragments.pop if fragments.last.is_a?(Hash)
162
+
163
+ [type, *combine_css_selectors(fragments), options].compact
164
+ end
165
+
166
+ # Internal: Combines parent and child classes to preserve the order.
167
+ def combine_css_selectors(selectors)
168
+ return selectors unless selectors.size > 1 && selectors.all? { |selector| selector.is_a?(String) }
169
+
170
+ selectors.reduce { |parent_selectors, children_selectors|
171
+ parent_selectors.split(SELECTOR_SEPARATOR).flat_map { |parent_selector|
172
+ children_selectors.split(SELECTOR_SEPARATOR).map { |children_selector|
173
+ "#{ parent_selector }#{ children_selector }"
174
+ }
175
+ }.join(SELECTOR_SEPARATOR)
176
+ }
177
+ end
178
+ end