capybara 3.8.0 → 3.9.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/History.md +25 -1
  3. data/lib/capybara/config.rb +1 -0
  4. data/lib/capybara/dsl.rb +2 -2
  5. data/lib/capybara/helpers.rb +1 -0
  6. data/lib/capybara/node/actions.rb +4 -0
  7. data/lib/capybara/node/base.rb +1 -0
  8. data/lib/capybara/node/element.rb +3 -0
  9. data/lib/capybara/node/finders.rb +2 -0
  10. data/lib/capybara/node/simple.rb +1 -0
  11. data/lib/capybara/queries/base_query.rb +1 -0
  12. data/lib/capybara/queries/match_query.rb +1 -0
  13. data/lib/capybara/queries/selector_query.rb +34 -37
  14. data/lib/capybara/queries/text_query.rb +2 -0
  15. data/lib/capybara/rack_test/browser.rb +1 -0
  16. data/lib/capybara/rack_test/driver.rb +5 -0
  17. data/lib/capybara/rack_test/node.rb +2 -0
  18. data/lib/capybara/result.rb +2 -0
  19. data/lib/capybara/rspec/compound.rb +2 -0
  20. data/lib/capybara/rspec/matchers.rb +1 -0
  21. data/lib/capybara/selector/builders/css_builder.rb +49 -0
  22. data/lib/capybara/selector/builders/xpath_builder.rb +56 -0
  23. data/lib/capybara/selector/filter_set.rb +1 -0
  24. data/lib/capybara/selector/filters/base.rb +2 -0
  25. data/lib/capybara/selector/regexp_disassembler.rb +66 -0
  26. data/lib/capybara/selector/selector.rb +25 -5
  27. data/lib/capybara/selector.rb +14 -27
  28. data/lib/capybara/selenium/driver.rb +8 -1
  29. data/lib/capybara/selenium/driver_specializations/chrome_driver.rb +19 -1
  30. data/lib/capybara/selenium/driver_specializations/marionette_driver.rb +1 -0
  31. data/lib/capybara/selenium/node.rb +11 -0
  32. data/lib/capybara/selenium/nodes/chrome_node.rb +2 -0
  33. data/lib/capybara/selenium/nodes/marionette_node.rb +37 -20
  34. data/lib/capybara/server/animation_disabler.rb +1 -0
  35. data/lib/capybara/server.rb +4 -0
  36. data/lib/capybara/session/config.rb +2 -0
  37. data/lib/capybara/session.rb +5 -0
  38. data/lib/capybara/spec/session/has_css_spec.rb +16 -0
  39. data/lib/capybara/spec/session/has_field_spec.rb +4 -0
  40. data/lib/capybara/spec/session/node_spec.rb +6 -0
  41. data/lib/capybara/spec/session/node_wrapper_spec.rb +1 -1
  42. data/lib/capybara/spec/session/reset_session_spec.rb +15 -1
  43. data/lib/capybara/spec/session/selectors_spec.rb +12 -2
  44. data/lib/capybara/spec/views/form.erb +15 -0
  45. data/lib/capybara/version.rb +1 -1
  46. data/lib/capybara/xpath_patches.rb +27 -0
  47. data/lib/capybara.rb +32 -10
  48. data/spec/dsl_spec.rb +15 -1
  49. data/spec/rack_test_spec.rb +6 -1
  50. data/spec/regexp_dissassembler_spec.rb +154 -0
  51. data/spec/selector_spec.rb +53 -18
  52. data/spec/selenium_spec_chrome.rb +28 -0
  53. data/spec/selenium_spec_firefox_remote.rb +2 -0
  54. data/spec/selenium_spec_marionette.rb +11 -0
  55. data/spec/shared_selenium_session.rb +20 -0
  56. data/spec/spec_helper.rb +4 -0
  57. metadata +7 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 814367062843328d421eb8c9db0476cabe5a16405866951855507dd63250bfb9
4
- data.tar.gz: 2b6605e124b8eed5d4efbc7f2e06d56dcf2ddaa8775d2ec7f42b18aca4144f3a
3
+ metadata.gz: 42a9311d8f3e7670a90f5851cc9927db169022d550e1c9b2a086900145b7ad18
4
+ data.tar.gz: 8159deadf9826514634c55f9744d2b8f18a910434d71f66870eb299f6c5980ff
5
5
  SHA512:
6
- metadata.gz: 76d6d964d8e204497e9f57b61e96ecf32631325c6c4b600ead18009817f45e324f241520d134881bfa62bba754d820f5e1d91710e4c78ca98ccdd06325a206fb
7
- data.tar.gz: ee215bc20878dcce34e1df44f2081e3867528358bd1c7cae542b5996e945ca1f6c16decba834ec3c13bd46052873c6988ef7c141674648eae8f5894fec405f38
6
+ metadata.gz: 8d8256a0c4bc0ca9586d65aec40d4814c060226a7902f5122271d4c9e22b63eec1568c81315d327e02873d732062beef6a0c01d0a50df9d49e14fcd0189084c0
7
+ data.tar.gz: a86ce539c90c9fbaea0ac8165bbf6f1b66ee8fa4589c129c366d88730cb1881616a3260c3a710b546c0e833aa708de8791326cbaa9c5ffe5f8875ce249c779cd
data/History.md CHANGED
@@ -1,9 +1,33 @@
1
+ # Version 3.9.0
2
+ Release date: 2018-10-03
3
+
4
+ ### Added
5
+
6
+ * Selenium with Chrome removes all cookies at session reset instead of just cookies from current domain if possible
7
+ * Support for Regexp for system :id and :class filters where possible
8
+ * `using_session` now accepts a session object as well as the name of the session for users who manually manage sessions
9
+ * The `:field` selector will now find `type = "hidden"` fields if the `type: "hidden"` filter option is provided
10
+
11
+ # Version 3.8.2
12
+ Release date: 2018-09-26
13
+
14
+ ### Fixed
15
+
16
+ * Fixed negated class selector option - Issue #2103
17
+
18
+ # Version 3.8.1
19
+ Release date: 2018-09-22
20
+
21
+ ### Fixed
22
+
23
+ * Filling in of date fields with a string when using selenium chrome regression [Micah Geisel]
24
+
1
25
  # Version 3.8.0
2
26
  Release date: 2018-09-20
3
27
 
4
28
  ### Added
5
29
 
6
- * Workaround gecodriver 0.22 issue with undefined pause durations
30
+ * Workaround geckodriver 0.22 issue with undefined pause durations
7
31
  * :element selector ignores XML namespaces
8
32
 
9
33
  ### Fixed
@@ -27,6 +27,7 @@ module Capybara
27
27
 
28
28
  def threadsafe=(bool)
29
29
  raise 'Threadsafe setting cannot be changed once a session is created' if (bool != threadsafe) && Session.instance_created?
30
+
30
31
  @threadsafe = bool
31
32
  end
32
33
 
data/lib/capybara/dsl.rb CHANGED
@@ -18,8 +18,8 @@ module Capybara
18
18
  #
19
19
  # Shortcut to working in a different session.
20
20
  #
21
- def using_session(name, &block)
22
- Capybara.using_session(name, &block)
21
+ def using_session(name_or_session, &block)
22
+ Capybara.using_session(name_or_session, &block)
23
23
  end
24
24
 
25
25
  # Shortcut to using a different wait time.
@@ -87,6 +87,7 @@ module Capybara
87
87
 
88
88
  def expired?
89
89
  raise Capybara::FrozenInTime, 'Time appears to be frozen. Capybara does not work with libraries which freeze time, consider using time travelling instead' if stalled?
90
+
90
91
  current - @start >= @expire_in
91
92
  end
92
93
 
@@ -274,6 +274,7 @@ module Capybara
274
274
  find(:select, from, options)
275
275
  rescue Capybara::ElementNotFound => select_error
276
276
  raise if %i[selected with_selected multiple].any? { |option| options.key?(option) }
277
+
277
278
  begin
278
279
  find(:datalist_input, from, options)
279
280
  rescue Capybara::ElementNotFound => dlinput_error
@@ -287,6 +288,7 @@ module Capybara
287
288
  datalist_options = input.evaluate_script(DATALIST_OPTIONS_SCRIPT)
288
289
  option = datalist_options.find { |opt| opt.values_at('value', 'label').include?(value) }
289
290
  raise ::Capybara::ElementNotFound, %(Unable to find datalist option "#{value}") unless option
291
+
290
292
  input.set(option['value'])
291
293
  rescue ::Capybara::NotSupportedByDriverError
292
294
  # Implement for drivers that don't support JS
@@ -299,6 +301,7 @@ module Capybara
299
301
  visible_css = { opacity: 1, display: 'block', visibility: 'visible' } if visible_css == true
300
302
  _update_style(element, visible_css)
301
303
  raise ExpectationNotMet, 'The style changes in :make_visible did not make the file input visible' unless element.visible?
304
+
302
305
  begin
303
306
  yield element
304
307
  ensure
@@ -326,6 +329,7 @@ module Capybara
326
329
  el.set(checked)
327
330
  rescue StandardError => err
328
331
  raise unless allow_label_click && catch_error?(err)
332
+
329
333
  begin
330
334
  el ||= find(selector, locator, options.merge(visible: :all))
331
335
  el.session.find(:label, for: el, visible: true).click unless el.checked? == checked
@@ -84,6 +84,7 @@ module Capybara
84
84
  session.raise_server_error!
85
85
  raise err unless driver.wait? && catch_error?(err, errors)
86
86
  raise err if timer.expired?
87
+
87
88
  sleep(0.05)
88
89
  reload if session_options.automatic_reload
89
90
  retry
@@ -84,6 +84,7 @@ module Capybara
84
84
  def style(*styles)
85
85
  styles = styles.flatten.map(&:to_s)
86
86
  raise ArgumentError, 'You must specify at least one CSS style' if styles.empty?
87
+
87
88
  begin
88
89
  synchronize { base.style(styles) }
89
90
  rescue NotImplementedError => err
@@ -113,6 +114,7 @@ module Capybara
113
114
  # @return [Capybara::Node::Element] The element
114
115
  def set(value, **options)
115
116
  raise Capybara::ReadOnlyElementError, "Attempt to set readonly element with value: #{value}" if readonly?
117
+
116
118
  options = session_options.default_set_options.to_h.merge(options)
117
119
  synchronize { base.set(value, options) }
118
120
  self
@@ -442,6 +444,7 @@ module Capybara
442
444
  %(#<Capybara::Node::Element tag="#{base.tag_name}">)
443
445
  rescue StandardError => err
444
446
  raise unless session.driver.invalid_element_errors.any? { |et| err.is_a?(et) }
447
+
445
448
  %(Obsolete #<Capybara::Node::Element>)
446
449
  end
447
450
 
@@ -252,10 +252,12 @@ module Capybara
252
252
  synchronize(query.wait) do
253
253
  result = query.resolve_for(self)
254
254
  raise Capybara::ExpectationNotMet, result.failure_message unless result.matches_count?
255
+
255
256
  result
256
257
  end
257
258
  rescue Capybara::ExpectationNotMet
258
259
  raise if minimum_specified || (result.compare_count == 1)
260
+
259
261
  Result.new([], nil)
260
262
  end
261
263
  end
@@ -182,6 +182,7 @@ module Capybara
182
182
 
183
183
  def option_value(option)
184
184
  return nil if option.nil?
185
+
185
186
  option[:value] || option.content
186
187
  end
187
188
  end
@@ -51,6 +51,7 @@ module Capybara
51
51
  return false if options[:maximum] && (Integer(options[:maximum]) < count)
52
52
  return false if options[:minimum] && (Integer(options[:minimum]) > count)
53
53
  return false if options[:between] && !options[:between].include?(count)
54
+
54
55
  true
55
56
  end
56
57
 
@@ -14,6 +14,7 @@ module Capybara
14
14
  unless invalid_options.empty?
15
15
  raise ArgumentError, "Match queries don't support quantity options. Invalid keys - #{invalid_options.join(', ')}"
16
16
  end
17
+
17
18
  super
18
19
  end
19
20
 
@@ -59,8 +59,11 @@ module Capybara
59
59
 
60
60
  def matches_filters?(node)
61
61
  return true if (@resolved_node&.== node) && options[:allow_self]
62
+
62
63
  @applied_filters ||= :system
64
+ return false unless matches_id_filter?(node) && matches_class_filter?(node)
63
65
  return false unless matches_text_filter?(node) && matches_exact_text_filter?(node) && matches_visible_filter?(node)
66
+
64
67
  @applied_filters = :node
65
68
  matches_node_filters?(node) && matches_filter_block?(node)
66
69
  rescue *(node.respond_to?(:session) ? node.session.driver.invalid_element_errors : [])
@@ -68,7 +71,7 @@ module Capybara
68
71
  end
69
72
 
70
73
  def visible
71
- case (vis = options.fetch(:visible) { @selector.default_visibility(session_options.ignore_hidden_elements) })
74
+ case (vis = options.fetch(:visible) { @selector.default_visibility(session_options.ignore_hidden_elements, options) })
72
75
  when true then :visible
73
76
  when false then :all
74
77
  else vis
@@ -174,6 +177,7 @@ module Capybara
174
177
 
175
178
  def matches_filter_block?(node)
176
179
  return true unless @filter_block
180
+
177
181
  if node.respond_to?(:session)
178
182
  node.session.using_wait_time(0) { @filter_block.call(node) }
179
183
  else
@@ -203,6 +207,7 @@ module Capybara
203
207
  unless VALID_MATCH.include?(match)
204
208
  raise ArgumentError, "invalid option #{match.inspect} for :match, should be one of #{VALID_MATCH.map(&:inspect).join(', ')}"
205
209
  end
210
+
206
211
  unhandled_options = @options.keys.reject do |option_name|
207
212
  valid_keys.include?(option_name) ||
208
213
  expression_filters.any? { |_name, ef| ef.handles_option? option_name } ||
@@ -210,28 +215,22 @@ module Capybara
210
215
  end
211
216
 
212
217
  return if unhandled_options.empty?
218
+
213
219
  invalid_names = unhandled_options.map(&:inspect).join(', ')
214
220
  valid_names = (valid_keys - [:allow_self]).map(&:inspect).join(', ')
215
221
  raise ArgumentError, "invalid keys #{invalid_names}, should be one of #{valid_names}"
216
222
  end
217
223
 
218
224
  def filtered_xpath(expr)
219
- if use_default_id_filter?
220
- id_xpath = if options[:id].is_a? XPath::Expression
221
- XPath.attr(:id)[options[:id]]
222
- else
223
- XPath.attr(:id) == options[:id]
224
- end
225
- expr = "(#{expr})[#{id_xpath}]"
226
- end
227
- expr = "(#{expr})[#{xpath_from_classes}]" if use_default_class_filter?
225
+ expr = "(#{expr})[#{conditions_from_id}]" if use_default_id_filter?
226
+ expr = "(#{expr})[#{conditions_from_classes}]" if use_default_class_filter?
228
227
  expr
229
228
  end
230
229
 
231
230
  def filtered_css(expr)
232
231
  ::Capybara::Selector::CSS.split(expr).map do |sel|
233
- sel += css_from_id if use_default_id_filter?
234
- sel += css_from_classes if use_default_class_filter?
232
+ sel += conditions_from_id if use_default_id_filter?
233
+ sel += conditions_from_classes if use_default_class_filter?
235
234
  sel
236
235
  end.join(', ')
237
236
  end
@@ -244,33 +243,12 @@ module Capybara
244
243
  options.key?(:class) && !custom_keys.include?(:class)
245
244
  end
246
245
 
247
- def css_from_classes
248
- if options[:class].is_a?(XPath::Expression)
249
- raise ArgumentError, 'XPath expressions are not supported for the :class filter with CSS based selectors'
250
- end
251
-
252
- classes = Array(options[:class]).group_by { |cl| cl.start_with? '!' }
253
- (classes[false].to_a.map { |cl| ".#{Capybara::Selector::CSS.escape(cl)}" } +
254
- classes[true].to_a.map { |cl| ":not(.#{Capybara::Selector::CSS.escape(cl.slice(1))})" }).join
246
+ def conditions_from_classes
247
+ builder.class_conditions(options[:class])
255
248
  end
256
249
 
257
- def css_from_id
258
- if options[:id].is_a?(XPath::Expression)
259
- raise ArgumentError, 'XPath expressions are not supported for the :id filter with CSS based selectors'
260
- end
261
- "##{::Capybara::Selector::CSS.escape(options[:id])}"
262
- end
263
-
264
- def xpath_from_classes
265
- return XPath.attr(:class)[options[:class]] if options[:class].is_a?(XPath::Expression)
266
-
267
- Array(options[:class]).map do |klass|
268
- if klass.start_with?('!')
269
- !XPath.attr(:class).contains_word(klass.slice(1))
270
- else
271
- XPath.attr(:class).contains_word(klass)
272
- end
273
- end.reduce(:&)
250
+ def conditions_from_id
251
+ builder.attribute_conditions(id: options[:id])
274
252
  end
275
253
 
276
254
  def apply_expression_filters(expression)
@@ -294,6 +272,7 @@ module Capybara
294
272
 
295
273
  def warn_exact_usage
296
274
  return unless options.key?(:exact) && !supports_exact?
275
+
297
276
  warn "The :exact option only has an effect on queries using the XPath#is method. Using it with the query \"#{expression}\" has no effect."
298
277
  end
299
278
 
@@ -313,16 +292,30 @@ module Capybara
313
292
  node.is_a?(::Capybara::Node::Simple) && node.path == '/'
314
293
  end
315
294
 
295
+ def matches_id_filter?(node)
296
+ return true unless use_default_id_filter? && options[:id].is_a?(Regexp)
297
+
298
+ node[:id] =~ options[:id]
299
+ end
300
+
301
+ def matches_class_filter?(node)
302
+ return true unless use_default_class_filter? && options[:class].is_a?(Regexp)
303
+
304
+ node[:class] =~ options[:class]
305
+ end
306
+
316
307
  def matches_text_filter?(node)
317
308
  value = options[:text]
318
309
  return true unless value
319
310
  return matches_text_exactly?(node, value) if exact_text == true
311
+
320
312
  regexp = value.is_a?(Regexp) ? value : Regexp.escape(value.to_s)
321
313
  matches_text_regexp?(node, regexp)
322
314
  end
323
315
 
324
316
  def matches_exact_text_filter?(node)
325
317
  return true unless exact_text.is_a?(String)
318
+
326
319
  matches_text_exactly?(node, exact_text)
327
320
  end
328
321
 
@@ -348,6 +341,10 @@ module Capybara
348
341
  text_visible = :all if text_visible == :hidden
349
342
  !!node.text(text_visible, normalize_ws: normalize_ws).match(regexp)
350
343
  end
344
+
345
+ def builder
346
+ selector.builder
347
+ end
351
348
  end
352
349
  end
353
350
  end
@@ -64,6 +64,7 @@ module Capybara
64
64
  insensitive_regexp = Capybara::Helpers.to_regexp(@expected_text, options: Regexp::IGNORECASE)
65
65
  insensitive_count = @actual_text.scan(insensitive_regexp).size
66
66
  return if insensitive_count == @count
67
+
67
68
  "it was found #{insensitive_count} #{Capybara::Helpers.declension('time', 'times', insensitive_count)} using a case insensitive search"
68
69
  end
69
70
 
@@ -71,6 +72,7 @@ module Capybara
71
72
  invisible_text = text(query_type: :all)
72
73
  invisible_count = invisible_text.scan(@search_regexp).size
73
74
  return if invisible_count == @count
75
+
74
76
  "it was found #{invisible_count} #{Capybara::Helpers.declension('time', 'times', invisible_count)} including non-visible text"
75
77
  rescue StandardError
76
78
  # An error getting the non-visible text (if element goes out of scope) should not affect the response
@@ -35,6 +35,7 @@ class Capybara::RackTest::Browser
35
35
 
36
36
  def follow(method, path, **attributes)
37
37
  return if fragment_or_script?(path)
38
+
38
39
  process_and_follow_redirects(method, path, attributes, 'HTTP_REFERER' => current_url)
39
40
  end
40
41
 
@@ -16,6 +16,7 @@ class Capybara::RackTest::Driver < Capybara::Driver::Base
16
16
 
17
17
  def initialize(app, **options)
18
18
  raise ArgumentError, 'rack-test requires a rack application, but none was given' unless app
19
+
19
20
  @app = app
20
21
  @options = DEFAULT_OPTIONS.merge(options)
21
22
  end
@@ -74,6 +75,10 @@ class Capybara::RackTest::Driver < Capybara::Driver::Base
74
75
 
75
76
  def find_css(selector)
76
77
  browser.find(:css, selector)
78
+ rescue Nokogiri::CSS::SyntaxError
79
+ raise unless selector.include?(' i]')
80
+
81
+ raise ArgumentError, "This driver doesn't support case insensitive attribute matching when using CSS base selectors"
77
82
  end
78
83
 
79
84
  def html
@@ -50,12 +50,14 @@ class Capybara::RackTest::Node < Capybara::Driver::Node
50
50
 
51
51
  def select_option
52
52
  return if disabled?
53
+
53
54
  deselect_options unless select_node.multiple?
54
55
  native['selected'] = 'selected'
55
56
  end
56
57
 
57
58
  def unselect_option
58
59
  raise Capybara::UnselectNotAllowed, 'Cannot unselect option from single select box.' unless select_node.multiple?
60
+
59
61
  native.remove_attribute('selected')
60
62
  end
61
63
 
@@ -93,6 +93,7 @@ module Capybara
93
93
  min, max = between.minmax
94
94
  size = load_up_to(max + 1)
95
95
  return 0 if between.include? size
96
+
96
97
  return size <=> min
97
98
  end
98
99
 
@@ -130,6 +131,7 @@ module Capybara
130
131
  def load_up_to(num)
131
132
  loop do
132
133
  break if @result_cache.size >= num
134
+
133
135
  @result_cache << @results_enum.next
134
136
  end
135
137
  @result_cache.size
@@ -43,6 +43,7 @@ if defined?(::RSpec::Expectations::Version)
43
43
  syncer.synchronize do
44
44
  @evaluator.reset
45
45
  raise ::Capybara::ElementNotFound unless [matcher_1_matches?, matcher_2_matches?].all?
46
+
46
47
  true
47
48
  end
48
49
  rescue StandardError
@@ -71,6 +72,7 @@ if defined?(::RSpec::Expectations::Version)
71
72
  syncer.synchronize do
72
73
  @evaluator.reset
73
74
  raise ::Capybara::ElementNotFound unless [matcher_1_matches?, matcher_2_matches?].any?
75
+
74
76
  true
75
77
  end
76
78
  rescue StandardError
@@ -242,6 +242,7 @@ module Capybara
242
242
  timer = Capybara::Helpers.timer(expire_in: @wait_time)
243
243
  while window.exists?
244
244
  return false if timer.expired?
245
+
245
246
  sleep 0.05
246
247
  end
247
248
  true
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'xpath'
4
+
5
+ module Capybara
6
+ class Selector
7
+ # @api private
8
+ class CSSBuilder
9
+ class << self
10
+ def attribute_conditions(attributes)
11
+ attributes.map do |attribute, value|
12
+ case value
13
+ when XPath::Expression
14
+ raise ArgumentError, "XPath expressions are not supported for the :#{attribute} filter with CSS based selectors"
15
+ when Regexp
16
+ Selector::RegexpDisassembler.new(value).substrings.map do |str|
17
+ "[#{attribute}*='#{str}'#{' i' if value.casefold?}]"
18
+ end.join
19
+ when true
20
+ "[#{attribute}]"
21
+ when false
22
+ ':not([attribute])'
23
+ else
24
+ if attribute == :id
25
+ "##{::Capybara::Selector::CSS.escape(value)}"
26
+ else
27
+ "[#{attribute}='#{value}']"
28
+ end
29
+ end
30
+ end.join
31
+ end
32
+
33
+ def class_conditions(classes)
34
+ case classes
35
+ when XPath::Expression
36
+ raise ArgumentError, 'XPath expressions are not supported for the :class filter with CSS based selectors'
37
+ when Regexp
38
+ strs = Selector::RegexpDisassembler.new(classes).substrings
39
+ strs.map { |str| "[class*='#{str}'#{' i' if classes.casefold?}]" }.join
40
+ else
41
+ cls = Array(classes).group_by { |cl| cl.start_with? '!' }
42
+ (cls[false].to_a.map { |cl| ".#{Capybara::Selector::CSS.escape(cl)}" } +
43
+ cls[true].to_a.map { |cl| ":not(.#{Capybara::Selector::CSS.escape(cl.slice(1..-1))})" }).join
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'xpath'
4
+
5
+ module Capybara
6
+ class Selector
7
+ # @api private
8
+ class XPathBuilder
9
+ class << self
10
+ def attribute_conditions(attributes)
11
+ attributes.map do |attribute, value|
12
+ case value
13
+ when XPath::Expression
14
+ XPath.attr(attribute)[value]
15
+ when Regexp
16
+ XPath.attr(attribute)[regexp_to_xpath_conditions(value)]
17
+ when true
18
+ XPath.attr(attribute)
19
+ when false, nil
20
+ !XPath.attr(attribute)
21
+ else
22
+ XPath.attr(attribute) == value.to_s
23
+ end
24
+ end.reduce(:&)
25
+ end
26
+
27
+ def class_conditions(classes)
28
+ case classes
29
+ when XPath::Expression
30
+ XPath.attr(:class)[classes]
31
+ when Regexp
32
+ XPath.attr(:class)[regexp_to_xpath_conditions(classes)]
33
+ else
34
+ Array(classes).map do |klass|
35
+ if klass.start_with?('!')
36
+ !XPath.attr(:class).contains_word(klass.slice(1..-1))
37
+ else
38
+ XPath.attr(:class).contains_word(klass)
39
+ end
40
+ end.reduce(:&)
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def regexp_to_xpath_conditions(regexp)
47
+ condition = XPath.current
48
+ condition = condition.uppercase if regexp.casefold?
49
+ Selector::RegexpDisassembler.new(regexp).substrings.map do |str|
50
+ condition.contains(str)
51
+ end.reduce(:&)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -106,6 +106,7 @@ module Capybara
106
106
  def add_filter(name, filter_class, *types, matcher: nil, **options, &block)
107
107
  types.each { |type| options[type] = true }
108
108
  raise 'ArgumentError', ':default option is not supported for filters with a :matcher option' if matcher && options[:default]
109
+
109
110
  if filter_class <= Filters::ExpressionFilter
110
111
  @expression_filters[name] = filter_class.new(name, matcher, block, options)
111
112
  else
@@ -41,6 +41,7 @@ module Capybara
41
41
  def apply(subject, name, value, skip_value)
42
42
  return skip_value if skip?(value)
43
43
  raise ArgumentError, "Invalid value #{value.inspect} passed to #{self.class.name.split('::').last} #{name}#{" : #{@name}" if @name.is_a?(Regexp)}" unless valid_value?(value)
44
+
44
45
  if @block.arity == 2
45
46
  @block.call(subject, value)
46
47
  else
@@ -50,6 +51,7 @@ module Capybara
50
51
 
51
52
  def valid_value?(value)
52
53
  return true unless @options.key?(:valid_values)
54
+
53
55
  Array(@options[:valid_values]).any? { |valid| valid === value } # rubocop:disable Style/CaseEquality
54
56
  end
55
57
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ class Selector
5
+ # @api private
6
+ class RegexpDisassembler
7
+ def initialize(regexp)
8
+ @regexp = regexp
9
+ @regexp_source = regexp.source
10
+ end
11
+
12
+ def substrings
13
+ @substrings ||= begin
14
+ source = @regexp_source.dup
15
+ source.gsub!(/\\[^pgk]/, '.') # replace escaped characters with wildcard
16
+ source.gsub!(/\\[gk](?:<[^>]*>)?/, '.') # replace sub expressions and back references with wildcard
17
+ source.gsub!(/\\p\{[[:alpha:]]+\}?/, '.') # replace character properties with wildcard
18
+ source.gsub!(/\[\[:[a-z]+:\]\]/, '.') # replace posix classes with wildcard
19
+ while source.gsub!(/\[(?:[^\[\]]+)\]/, '.'); end # replace character classes with wildcard
20
+ source.gsub!(/\(\?<?[=!][^)]*\)/, '') # remove lookahead/lookbehind assertions
21
+ source.gsub!(/\(\?(?:<[^>]+>|>|:)/, '(') # replace named, atomic, and non-matching groups with unnamed matching groups
22
+
23
+ while source.gsub!(GROUP_REGEX) { |_m| simplify_group(Regexp.last_match) }; end
24
+ source.gsub!(/.[*?]\??/, '.') # replace optional character with wildcard
25
+ source.gsub!(/(.)\+\??/, '\1.') # replace one or more with character plus wildcard
26
+ source.gsub!(/(?<char>.)#{COUNTED_REP_REGEX.source}/) do |_m| # repeat counted characters
27
+ (Regexp.last_match[:char] * Regexp.last_match[:min_rep].to_i).tap { |str| str << '.' if Regexp.last_match[:max_rep] }
28
+ end
29
+ return [] if source.include?('|') # can't handle alternation here
30
+
31
+ strs = source.match(/\A\^?(.*?)\$?\Z/).captures[0].split('.').reject(&:empty?).uniq
32
+ strs = strs.map(&:upcase) if @regexp.casefold?
33
+ strs
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def simplify_group(matches)
40
+ if matches[:group].include?('|') # no support for alternation in groups
41
+ '.'
42
+ elsif matches[:one_or_more] # required but may repeat becomes text + wildcard
43
+ matches[:group][1..-2] + '.'
44
+ elsif matches[:optional] # optional group becomes wildcard
45
+ '.'
46
+ elsif matches[:min_rep]
47
+ (matches[:group] * matches[:min_rep].to_i).tap { |r| r << '.' if matches[:max_rep] }
48
+ else
49
+ matches[:group][1..-2]
50
+ end
51
+ end
52
+
53
+ COUNTED_REP_REGEX = /\{(?<min_rep>\d*)(?:,(?<max_rep>\d*))?\}/
54
+ GROUP_REGEX = /
55
+ (?<group>\([^()]*\))
56
+ (?:
57
+ (?:
58
+ (?<optional>[*?]) |
59
+ (?<one_or_more>\+) |
60
+ (?:#{COUNTED_REP_REGEX.source})
61
+ )\??
62
+ )?
63
+ /x
64
+ end
65
+ end
66
+ end