rubocop-capybara 3.0.0 → 3.1.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/config/default.yml +71 -3
  4. data/lib/rubocop/capybara/version.rb +1 -1
  5. data/lib/rubocop/cop/capybara/absolute_x_path_in_element_scope.rb +104 -0
  6. data/lib/rubocop/cop/capybara/always_true_count_query.rb +75 -0
  7. data/lib/rubocop/cop/capybara/ambiguous_click.rb +1 -1
  8. data/lib/rubocop/cop/capybara/assert_style.rb +1 -1
  9. data/lib/rubocop/cop/capybara/chained_find.rb +175 -0
  10. data/lib/rubocop/cop/capybara/find_all_first.rb +13 -41
  11. data/lib/rubocop/cop/capybara/ignored_query_option.rb +242 -0
  12. data/lib/rubocop/cop/capybara/mixin/capybara_help.rb +86 -2
  13. data/lib/rubocop/cop/capybara/mixin/css_attributes_parser.rb +23 -5
  14. data/lib/rubocop/cop/capybara/mixin/css_selector.rb +13 -10
  15. data/lib/rubocop/cop/capybara/mixin/query_methods.rb +120 -0
  16. data/lib/rubocop/cop/capybara/mixin/visibility.rb +53 -0
  17. data/lib/rubocop/cop/capybara/modal_method_without_block.rb +37 -0
  18. data/lib/rubocop/cop/capybara/native_attribute.rb +76 -0
  19. data/lib/rubocop/cop/capybara/redundant_within_find.rb +10 -21
  20. data/lib/rubocop/cop/capybara/rspec/current_path_expectation.rb +13 -16
  21. data/lib/rubocop/cop/capybara/rspec/current_url_expectation.rb +122 -0
  22. data/lib/rubocop/cop/capybara/rspec/find_element_value_eq.rb +173 -0
  23. data/lib/rubocop/cop/capybara/rspec/have_content.rb +1 -1
  24. data/lib/rubocop/cop/capybara/rspec/have_selector.rb +26 -15
  25. data/lib/rubocop/cop/capybara/rspec/match_style.rb +1 -1
  26. data/lib/rubocop/cop/capybara/rspec/negation_matcher.rb +1 -1
  27. data/lib/rubocop/cop/capybara/rspec/negation_matcher_after_visit.rb +2 -2
  28. data/lib/rubocop/cop/capybara/rspec/predicate_matcher.rb +43 -7
  29. data/lib/rubocop/cop/capybara/rspec/specific_matcher.rb +8 -2
  30. data/lib/rubocop/cop/capybara/rspec/visibility_matcher.rb +29 -24
  31. data/lib/rubocop/cop/capybara/rspec.rb +25 -0
  32. data/lib/rubocop/cop/capybara/specific_actions.rb +12 -7
  33. data/lib/rubocop/cop/capybara/specific_finders.rb +149 -28
  34. data/lib/rubocop/cop/capybara/unordered_window_access.rb +40 -0
  35. data/lib/rubocop/cop/capybara/visibility_option.rb +64 -0
  36. data/lib/rubocop/cop/capybara.rb +28 -0
  37. data/lib/rubocop/cop/capybara_cops.rb +4 -16
  38. data/lib/rubocop-capybara.rb +7 -2
  39. metadata +18 -4
@@ -0,0 +1,242 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Capybara
6
+ # Checks for query options that have no effect in Capybara.
7
+ #
8
+ # Count options passed to singular finders are not autocorrected because
9
+ # removing them may conceal the author's intent.
10
+ #
11
+ # This cop targets Capybara's built-in selectors. Set `DefaultSelector`
12
+ # to match `Capybara.default_selector`, or to `null` if it varies at
13
+ # runtime. Queries using custom or unknown selectors are skipped.
14
+ #
15
+ # @example
16
+ # # bad
17
+ # find('.row', count: 3)
18
+ # find(:css, '.item', exact: true)
19
+ # find('.item', text: /foo/, exact_text: true)
20
+ # all(:css, '.item', match: :first)
21
+ # expect(page).to have_css('.item', count: 3, minimum: 1)
22
+ #
23
+ # # good
24
+ # all('.row', count: 3)
25
+ # find(:css, '.item')
26
+ # find('.item', text: /foo/)
27
+ # all(:css, '.item')
28
+ # expect(page).to have_css('.item', count: 3)
29
+ #
30
+ # @example DefaultSelector: custom
31
+ # # bad
32
+ # find(:css, '.item', text: /foo/, exact_text: true)
33
+ #
34
+ # # good
35
+ # find('.item', text: /foo/, exact_text: true)
36
+ #
37
+ class IgnoredQueryOption < RuboCop::Cop::Base # rubocop:disable Metrics/ClassLength
38
+ extend AutoCorrector
39
+ include RangeHelp
40
+ include CapybaraHelp
41
+
42
+ RESTRICT_ON_SEND = QueryMethods.names(
43
+ :finder, :collection, :selector, :match, :text
44
+ ).freeze
45
+ COUNT_OPTIONS = %i[count minimum maximum between].freeze
46
+ MATCH_STRATEGIES = %i[first smart prefer_exact one].freeze
47
+
48
+ FIND_COUNT_MSG =
49
+ '`%<option>s` has no effect when passed to `%<method>s`.'
50
+ EXACT_MSG = '`exact` has no effect on CSS queries.'
51
+ EXACT_TEXT_MSG =
52
+ '`exact_text` has no effect when `text` is a regexp.'
53
+ MATCH_MSG =
54
+ '`match` has no effect when passed to `%<method>s`.'
55
+ SHADOWED_COUNT_MSG =
56
+ '`%<option>s` has no effect when `count` is specified.'
57
+
58
+ # @!method query_options(node)
59
+ def_node_matcher :query_options, <<~PATTERN
60
+ (call #capybara_receiver? _ <$(hash ...) ...>)
61
+ PATTERN
62
+
63
+ # @!method custom_filters?(node)
64
+ def_node_matcher :custom_filters?, <<~PATTERN
65
+ (hash <{(pair (sym {:filter_set :session_options}) _) (pair !sym _) kwsplat} ...>)
66
+ PATTERN
67
+
68
+ # @!method removable_value?(node)
69
+ def_node_matcher :removable_value?, <<~PATTERN
70
+ {basic_literal? (range {int nil?} {int nil?})}
71
+ PATTERN
72
+
73
+ def on_send(node)
74
+ return unless (options = query_options(node))
75
+ return if options.braces?
76
+ return if custom_filters?(options)
77
+
78
+ issues = options.pairs.filter_map do |pair|
79
+ issue_for(node, options, pair)
80
+ end
81
+ return if issues.empty?
82
+
83
+ register_issues(node, options, issues)
84
+ end
85
+ alias on_csend on_send
86
+
87
+ private
88
+
89
+ def register_issues(node, options, issues)
90
+ correction_pair = issues.find do |pair, _, correctable|
91
+ correctable && removable?(options, pair) && !comments_in?(node)
92
+ end
93
+ issues.each do |pair, message, _|
94
+ add_offense(pair, message: message) do |corrector|
95
+ next unless pair == correction_pair&.first
96
+
97
+ remove_option(corrector, node, options, pair)
98
+ end
99
+ end
100
+ end
101
+
102
+ def issue_for(node, options, pair)
103
+ option = pair.key.value
104
+ issue = find_count_issue(node, option) ||
105
+ exact_issue(node, option) ||
106
+ exact_text_issue(node, option, pair, options) ||
107
+ match_issue(node, option, pair) ||
108
+ shadowed_count_issue(node, option, options)
109
+
110
+ [pair, *issue] if issue
111
+ end
112
+
113
+ def find_count_issue(node, option)
114
+ return unless QueryMethods.type(node.method_name) == :finder
115
+ return unless COUNT_OPTIONS.include?(option)
116
+ return if custom_selector?(node)
117
+
118
+ [
119
+ format(FIND_COUNT_MSG,
120
+ option: option,
121
+ method: node.method_name),
122
+ false
123
+ ]
124
+ end
125
+
126
+ def shadowed_count_issue(node, option, options)
127
+ return if QueryMethods.type(node.method_name) == :match
128
+ return unless count_query_with_known_selector?(node)
129
+ return unless shadowed_count?(option, options)
130
+
131
+ [format(SHADOWED_COUNT_MSG, option: option), true]
132
+ end
133
+
134
+ def exact_issue(node, option)
135
+ [EXACT_MSG, true] if option == :exact && css_query?(node)
136
+ end
137
+
138
+ def exact_text_issue(node, option, pair, options)
139
+ return unless option == :exact_text
140
+ return unless ineffective_exact_text?(node, pair, options)
141
+
142
+ [EXACT_TEXT_MSG, true]
143
+ end
144
+
145
+ def match_issue(node, option, pair)
146
+ return unless option == :match && ineffective_match?(node, pair)
147
+
148
+ [format(MATCH_MSG, method: node.method_name), true]
149
+ end
150
+
151
+ def css_query?(node)
152
+ selector = QueryMethods.selector(node.method_name)
153
+ selector == :css ||
154
+ (selector == :selector && explicit_selector(node) == :css)
155
+ end
156
+
157
+ def explicit_selector(node)
158
+ argument = node.first_argument
159
+ argument.value if argument&.sym_type?
160
+ end
161
+
162
+ def ineffective_exact_text?(node, pair, options)
163
+ exact_text_supported_call?(node) &&
164
+ effective_option_pair(options, :exact_text) == pair &&
165
+ pair.value.type?(:boolean) &&
166
+ effective_option_pair(options, :text)&.value&.regexp_type?
167
+ end
168
+
169
+ def exact_text_supported_call?(node)
170
+ QueryMethods.type(node.method_name) != :text &&
171
+ !custom_selector?(node)
172
+ end
173
+
174
+ def ineffective_match?(node, pair)
175
+ QueryMethods.type(node.method_name) == :collection &&
176
+ QueryMethods.built_in_selector?(explicit_selector(node)) &&
177
+ pair.value.sym_type? &&
178
+ MATCH_STRATEGIES.include?(pair.value.value)
179
+ end
180
+
181
+ def shadowed_count?(option, options)
182
+ option != :count && COUNT_OPTIONS.include?(option) &&
183
+ count_specified?(options)
184
+ end
185
+
186
+ def count_query_with_known_selector?(node)
187
+ selector = QueryMethods.selector(node.method_name)
188
+ return true unless selector == :selector
189
+
190
+ QueryMethods.built_in_selector?(explicit_selector(node))
191
+ end
192
+
193
+ def custom_selector?(node)
194
+ QueryMethods.selector(node.method_name) == :selector &&
195
+ !QueryMethods.built_in_selector?(query_selector(node))
196
+ end
197
+
198
+ def query_selector(node)
199
+ selector = explicit_selector(node)
200
+ return selector if selector
201
+
202
+ positional = node.arguments.reject do |arg|
203
+ arg.type?(:hash, :block_pass)
204
+ end
205
+ return if positional.length > 1
206
+
207
+ cop_config['DefaultSelector'].to_s.to_sym
208
+ end
209
+
210
+ def count_specified?(options)
211
+ effective_option_pair(options, :count)&.value&.truthy_literal?
212
+ end
213
+
214
+ def effective_option_pair(options, name)
215
+ options.pairs.reverse_each.find { |pair| pair.key.value == name }
216
+ end
217
+
218
+ def comments_in?(node)
219
+ processed_source.comments.any? do |comment|
220
+ comment.loc.line.between?(node.first_line, node.last_line)
221
+ end
222
+ end
223
+
224
+ def removable?(options, pair)
225
+ return false unless removable_value?(pair.value)
226
+ return false if pair.value.str_type? && pair.value.heredoc?
227
+
228
+ options.pairs.one? { |other| other.key == pair.key }
229
+ end
230
+
231
+ def remove_option(corrector, node, options, pair)
232
+ first = node.first_argument == options &&
233
+ options.children.first == pair
234
+ side = first ? :right : :left
235
+ range = range_with_surrounding_space(pair.source_range, side: side)
236
+ range = range_with_surrounding_comma(range, side)
237
+ corrector.remove(range_with_surrounding_space(range, side: side))
238
+ end
239
+ end
240
+ end
241
+ end
242
+ end
@@ -5,11 +5,13 @@ module RuboCop
5
5
  module Capybara
6
6
  # Help methods for capybara.
7
7
  # @api private
8
- module CapybaraHelp
8
+ module CapybaraHelp # rubocop:disable Metrics/ModuleLength
9
+ extend RuboCop::AST::NodePattern::Macros
10
+
9
11
  CAPYBARA_MATCHERS = %w[
10
12
  selector css xpath text title current_path link button
11
13
  field checked_field unchecked_field select table
12
- sibling ancestor content
14
+ sibling ancestor content element
13
15
  ].freeze
14
16
  POSITIVE_MATCHERS =
15
17
  Set.new(CAPYBARA_MATCHERS) { |element| :"have_#{element}" }.freeze
@@ -45,9 +47,91 @@ module RuboCop
45
47
  SPECIFIC_PSEUDO_CLASSES = %w[
46
48
  not() disabled enabled checked unchecked
47
49
  ].freeze
50
+ VISIBILITY_MATCHER_METHODS = %w[
51
+ button checked_field css field link select selector table
52
+ unchecked_field xpath
53
+ ].flat_map do |element|
54
+ [:"have_#{element}", :"have_no_#{element}"]
55
+ end.freeze
56
+ VISIBILITY_FALSE_MESSAGE = 'Use `:all` or `:hidden` instead of `false`.'
57
+ VISIBILITY_TRUE_MESSAGE = 'Use `:visible` instead of `true`.'
48
58
 
49
59
  module_function
50
60
 
61
+ # Recognize the Capybara DSL and finder chains, not arbitrary objects
62
+ # with methods such as Enumerable#find or ActiveRecord#first.
63
+ # Recursive references intentionally use the pattern-only matcher.
64
+ # Resolving local variables here would recurse forever for assignments
65
+ # such as `tr = tr.find(...)`.
66
+ # @!method capybara_receiver_pattern?(node)
67
+ def_node_matcher :capybara_receiver_pattern?, <<~PATTERN
68
+ {nil? self
69
+ (call {nil? self} :page)
70
+ (call (const {nil? cbase} :Capybara) :current_session)
71
+ (call #capybara_receiver_pattern?
72
+ {:all :ancestor :find :find_all :find_button :find_by_id
73
+ :find_field :find_link :first :sibling} ...)
74
+ (any_block #capybara_receiver_pattern? ...)
75
+ (begin #capybara_receiver_pattern?) }
76
+ PATTERN
77
+ module_function :capybara_receiver_pattern?
78
+
79
+ # @param node [RuboCop::AST::Node]
80
+ # @return [Boolean]
81
+ def capybara_receiver?(node)
82
+ capybara_receiver_pattern?(node) ||
83
+ local_variable_capybara_receiver?(node)
84
+ end
85
+ module_function :capybara_receiver?
86
+
87
+ # ponytail: Only preceding direct assignments in visible lexical scopes;
88
+ # use data-flow analysis if coverage grows.
89
+ def local_variable_capybara_receiver?(node)
90
+ value = local_variable_value(node)
91
+ value && capybara_receiver_pattern?(value)
92
+ end
93
+ module_function :local_variable_capybara_receiver?
94
+
95
+ def local_variable_value(node)
96
+ return unless node.lvar_type?
97
+
98
+ name = node.children.first
99
+ scopes = node.each_ancestor.take_while do |scope|
100
+ outer_assignment_visible_through?(scope, name)
101
+ end
102
+ assignment = scopes.filter_map do |scope|
103
+ local_variable_assignment(node, scope) if scope.begin_type?
104
+ end.first
105
+ assignment && assignment.children[1]
106
+ end
107
+ module_function :local_variable_value
108
+
109
+ def outer_assignment_visible_through?(scope, name)
110
+ return false if scope.type?(:any_def, :class, :module, :sclass)
111
+
112
+ !scope.block_type? ||
113
+ !shadowed_block_variable?(scope.arguments, name)
114
+ end
115
+ module_function :outer_assignment_visible_through?
116
+
117
+ def shadowed_block_variable?(arguments, name)
118
+ args = arguments.each_node(
119
+ :arg, :optarg, :restarg, :kwarg,
120
+ :kwoptarg, :kwrestarg, :blockarg, :shadowarg
121
+ )
122
+ args.any? { |arg| arg.children.first == name }
123
+ end
124
+ module_function :shadowed_block_variable?
125
+
126
+ def local_variable_assignment(node, scope)
127
+ scope.children.reverse.find do |child|
128
+ child.lvasgn_type? &&
129
+ child.children.first == node.children.first &&
130
+ child.source_range.end_pos <= node.source_range.begin_pos
131
+ end
132
+ end
133
+ module_function :local_variable_assignment
134
+
51
135
  # @param node [RuboCop::AST::SendNode]
52
136
  # @param locator [String]
53
137
  # @param element [String]
@@ -6,6 +6,8 @@ module RuboCop
6
6
  # Css selector parser.
7
7
  # @api private
8
8
  class CssAttributesParser
9
+ QUOTE_CHARS = ['"', "'"].freeze
10
+
9
11
  def initialize(selector)
10
12
  @selector = selector
11
13
  @state = :initial
@@ -14,7 +16,7 @@ module RuboCop
14
16
  @bracket_count = 0
15
17
  end
16
18
 
17
- # @return [Array<String>]
19
+ # @return [Hash<String, String, Boolean, nil>]
18
20
  def parse # rubocop:disable Metrics/MethodLength
19
21
  @selector.each_char do |char|
20
22
  if char == '['
@@ -43,7 +45,7 @@ module RuboCop
43
45
  @bracket_count -= 1
44
46
  if @bracket_count.zero?
45
47
  @state = :initial
46
- key, value = @temp.split('=')
48
+ key, value = @temp.split('=', 2)
47
49
  @results[key] = normalize_value(value)
48
50
  @temp.clear
49
51
  else
@@ -52,20 +54,36 @@ module RuboCop
52
54
  end
53
55
 
54
56
  # @param value [String]
55
- # @return [Boolean, String]
57
+ # @return [Boolean, String, nil]
56
58
  # @example
57
59
  # normalize_value('true') # => true
58
60
  # normalize_value('false') # => false
59
61
  # normalize_value(nil) # => nil
60
- # normalize_value("foo") # => "'foo'"
62
+ # normalize_value("foo") # => "foo"
63
+ # normalize_value("'foo'") # => "foo"
61
64
  def normalize_value(value)
65
+ return if value&.match?(/\s+[is]\s*\z/i)
66
+
62
67
  case value
63
68
  when 'true' then true
64
69
  when 'false' then false
65
70
  when nil then nil
66
- else "'#{value.gsub(/"|'/, '')}'"
71
+ else strip_outer_quotes(value)
67
72
  end
68
73
  end
74
+
75
+ def strip_outer_quotes(value)
76
+ return value unless quoted?(value)
77
+
78
+ value[1...-1]
79
+ end
80
+
81
+ def quoted?(value)
82
+ return false if value.length < 2
83
+
84
+ quote = value[0]
85
+ QUOTE_CHARS.include?(quote) && value.end_with?(quote)
86
+ end
69
87
  end
70
88
  end
71
89
  end
@@ -8,6 +8,8 @@ module RuboCop
8
8
  module CssSelector
9
9
  module_function
10
10
 
11
+ ID_PATTERN = /\A#((?:[\w-]|\\[.>,+~])+)(?:\.|\z)/.freeze
12
+
11
13
  # @param selector [String]
12
14
  # @return [String]
13
15
  # @example
@@ -15,9 +17,7 @@ module RuboCop
15
17
  # id('.some-cls') # => nil
16
18
  # id('#some-id.cls') # => some-id
17
19
  def id(selector)
18
- return unless id?(selector)
19
-
20
- selector.delete('#').gsub(selector.scan(/[^\\]([>,+~.].*)/).join, '')
20
+ selector[ID_PATTERN, 1]
21
21
  end
22
22
 
23
23
  # @param selector [String]
@@ -26,18 +26,21 @@ module RuboCop
26
26
  # id?('#some-id') # => true
27
27
  # id?('.some-cls') # => false
28
28
  def id?(selector)
29
- selector.start_with?('#')
29
+ !id(selector).nil?
30
30
  end
31
31
 
32
32
  # @param selector [String]
33
- # @return [Array<String>]
33
+ # @return [Hash<String, String, Boolean, nil>]
34
34
  # @example
35
35
  # classes('#some-id') # => []
36
36
  # classes('.some-cls') # => ['some-cls']
37
37
  # classes('#some-id.some-cls') # => ['some-cls']
38
38
  # classes('#some-id.cls1.cls2') # => ['cls1', 'cls2']
39
+ # classes('.some-cls\.with-dot') # => ['some-cls.with-dot']
39
40
  def classes(selector)
40
- selector.scan(/\.([\w-]*)/).flatten
41
+ selector.scan(/(?<!\\)\.((?:\\.|[\w-])+)/).flatten.map do |class_name|
42
+ class_name.gsub('\\.', '.')
43
+ end
41
44
  end
42
45
 
43
46
  # @param selector [String]
@@ -50,12 +53,12 @@ module RuboCop
50
53
  end
51
54
 
52
55
  # @param selector [String]
53
- # @return [Array<String>]
56
+ # @return [Hash<String, String, Boolean, nil>]
54
57
  # @example
55
58
  # attributes('a[foo-bar_baz]') # => {"foo-bar_baz=>nil}
56
- # attributes('button[foo][bar=baz]') # => {"foo"=>nil, "bar"=>"'baz'"}
57
- # attributes('table[foo=bar]') # => {"foo"=>"'bar'"}
58
- # attributes('[foo="bar[baz][qux]"]') # => {"foo"=>"'bar[baz][qux]'"}
59
+ # attributes('button[foo][bar=baz]') # => {"foo"=>nil, "bar"=>"baz"}
60
+ # attributes('table[foo=bar]') # => {"foo"=>"bar"}
61
+ # attributes('[foo="bar[baz][qux]"]') # => {"foo"=>"bar[baz][qux]"}
59
62
  def attributes(selector)
60
63
  CssAttributesParser.new(selector).parse
61
64
  end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Capybara
6
+ # Classification of Capybara's public query methods.
7
+ # @api private
8
+ module QueryMethods
9
+ BUILT_IN_SELECTORS = %i[
10
+ button checkbox css datalist_input datalist_option element field
11
+ fieldset file_field fillable_field frame id label link link_or_button
12
+ option radio_button select table table_row xpath
13
+ ].freeze
14
+
15
+ # Each entry contains the query type and its selector. `:selector`
16
+ # means the selector is an argument; nil means it is not applicable.
17
+ METHODS = begin
18
+ methods = {
19
+ find: %i[finder selector],
20
+ ancestor: %i[finder selector],
21
+ sibling: %i[finder selector],
22
+ find_button: %i[finder button],
23
+ find_by_id: %i[finder id],
24
+ find_field: %i[finder field],
25
+ find_link: %i[finder link],
26
+ within: %i[finder selector]
27
+ }
28
+ %i[all find_all first].each do |name|
29
+ methods[name] = %i[collection selector]
30
+ end
31
+
32
+ %w[
33
+ ancestor button checked_field css element field link select
34
+ selector sibling table unchecked_field xpath
35
+ ].each do |selector|
36
+ kind = case selector
37
+ when 'ancestor', 'sibling' then :selector
38
+ when 'checked_field', 'unchecked_field' then :field
39
+ else selector.to_sym
40
+ end
41
+ prefixes = %w[assert assert_no refute has has_no have have_no]
42
+ prefixes += %w[must_have wont_have] unless selector == 'element'
43
+ prefixes.each do |prefix|
44
+ name = "#{prefix}_#{selector}"
45
+ name += '?' if prefix.start_with?('has')
46
+ methods[name.to_sym] = [:selector, kind]
47
+ end
48
+ end
49
+
50
+ %w[css selector xpath].each do |selector|
51
+ %W[
52
+ assert_matches_#{selector} assert_not_matches_#{selector}
53
+ refute_matches_#{selector} matches_#{selector}?
54
+ not_matches_#{selector}? match_#{selector} not_match_#{selector}
55
+ must_match_#{selector} wont_match_#{selector}
56
+ ].each { |name| methods[name.to_sym] = [:match, selector.to_sym] }
57
+ end
58
+
59
+ %w[text content].each do |kind|
60
+ %W[
61
+ assert_#{kind} assert_no_#{kind} refute_#{kind}
62
+ has_#{kind}? has_no_#{kind}? have_#{kind} have_no_#{kind}
63
+ must_have_#{kind} wont_have_#{kind}
64
+ ].each { |name| methods[name.to_sym] = [:text, nil] }
65
+ end
66
+
67
+ %i[
68
+ attach_file check choose click_button click_link
69
+ click_link_or_button click_on fill_in select uncheck unselect
70
+ ].each { |name| methods[name] = [:action, nil] }
71
+
72
+ %w[assert have must_have].product(%w[all any none]) do |prefix, count|
73
+ methods[:"#{prefix}_#{count}_of_selectors"] = %i[grouped selector]
74
+ end
75
+
76
+ methods.transform_values(&:freeze).freeze
77
+ end
78
+ private_constant :METHODS, :BUILT_IN_SELECTORS
79
+
80
+ module_function
81
+
82
+ # @param types [Array<Symbol>]
83
+ # @return [Array<Symbol>]
84
+ # @example
85
+ # names(:collection) # => [:all, :find_all, :first]
86
+ def names(*types)
87
+ METHODS.filter_map { |name, (type, _)| name if types.include?(type) }
88
+ end
89
+
90
+ # @param method_name [Symbol]
91
+ # @return [Symbol, nil]
92
+ # @example
93
+ # type(:find) # => :finder
94
+ # type(:unknown) # => nil
95
+ def type(method_name)
96
+ METHODS[method_name]&.first
97
+ end
98
+
99
+ # @param method_name [Symbol]
100
+ # @return [Symbol, nil]
101
+ # @example
102
+ # selector(:find_button) # => :button
103
+ # selector(:find) # => :selector
104
+ # selector(:have_text) # => nil
105
+ def selector(method_name)
106
+ METHODS[method_name]&.last
107
+ end
108
+
109
+ # @param selector [Symbol]
110
+ # @return [Boolean]
111
+ # @example
112
+ # built_in_selector?(:css) # => true
113
+ # built_in_selector?(:custom) # => false
114
+ def built_in_selector?(selector)
115
+ BUILT_IN_SELECTORS.include?(selector)
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Capybara
6
+ # Shared visibility query checks and offense handling.
7
+ # @api private
8
+ module Visibility
9
+ extend RuboCop::AST::NodePattern::Macros
10
+
11
+ # @!method custom_filters?(node)
12
+ def_node_matcher :custom_filters?, <<~PATTERN
13
+ (hash <{(pair (sym :filter_set) _) (pair !sym _) kwsplat} ...>)
14
+ PATTERN
15
+
16
+ private
17
+
18
+ def supported_visibility_query?(node, options)
19
+ return false if options.braces? || custom_filters?(options)
20
+ return true if QueryMethods.selector(node.method_name) != :selector
21
+
22
+ QueryMethods.built_in_selector?(query_selector(node))
23
+ end
24
+
25
+ def query_selector(node)
26
+ selector = node.first_argument
27
+ return selector.value if selector&.sym_type?
28
+
29
+ positional = node.arguments.reject do |arg|
30
+ arg.type?(:hash, :block_pass)
31
+ end
32
+ return if positional.length > 1 && !selector.str_type?
33
+
34
+ cop_config['DefaultSelector'].to_s.to_sym
35
+ end
36
+
37
+ def check_visibility(pair)
38
+ if pair.value.false_type?
39
+ return add_offense(pair, message: CapybaraHelp::VISIBILITY_FALSE_MESSAGE)
40
+ end
41
+
42
+ add_offense(
43
+ pair, message: CapybaraHelp::VISIBILITY_TRUE_MESSAGE
44
+ ) do |corrector|
45
+ next if block_given? && !yield
46
+
47
+ corrector.replace(pair.value, ':visible')
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Capybara
6
+ # Checks that modal handling methods are called with a block.
7
+ #
8
+ # @example
9
+ # # bad
10
+ # accept_confirm
11
+ # page.accept_prompt(with: 'answer')
12
+ #
13
+ # # good
14
+ # accept_confirm { click_button 'Delete' }
15
+ # page.accept_prompt(with: 'answer', &trigger_modal)
16
+ #
17
+ class ModalMethodWithoutBlock < RuboCop::Cop::Base
18
+ MSG = 'Call `%<method>s` with a block.'
19
+ RESTRICT_ON_SEND = %i[
20
+ accept_alert
21
+ accept_confirm
22
+ accept_prompt
23
+ dismiss_confirm
24
+ dismiss_prompt
25
+ ].freeze
26
+
27
+ def on_send(node)
28
+ return if node.block_literal? || node.block_argument?
29
+ return if node.last_argument&.forwarded_args_type?
30
+
31
+ add_offense(node, message: format(MSG, method: node.method_name))
32
+ end
33
+ alias on_csend on_send
34
+ end
35
+ end
36
+ end
37
+ end