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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +27 -0
- data/README.md +124 -0
- data/lib/capybara/compose.rb +74 -0
- data/lib/capybara/compose/actions.rb +116 -0
- data/lib/capybara/compose/assertions.rb +142 -0
- data/lib/capybara/compose/benchmark_helpers.rb +87 -0
- data/lib/capybara/compose/cucumber.rb +12 -0
- data/lib/capybara/compose/dependency_injection.rb +44 -0
- data/lib/capybara/compose/finders.rb +40 -0
- data/lib/capybara/compose/matchers.rb +79 -0
- data/lib/capybara/compose/rspec.rb +33 -0
- data/lib/capybara/compose/selectors.rb +178 -0
- data/lib/capybara/compose/synchronization.rb +41 -0
- data/lib/capybara/compose/test_helper.rb +171 -0
- data/lib/capybara/compose/to_or_expectation_handler.rb +23 -0
- data/lib/capybara/compose/version.rb +8 -0
- data/lib/generators/test_helper/test_helper_generator.rb +34 -0
- metadata +106 -0
@@ -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
|