capybara 3.10.1 → 3.11.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/History.md +13 -0
  3. data/README.md +2 -3
  4. data/lib/capybara.rb +16 -6
  5. data/lib/capybara/minitest.rb +8 -9
  6. data/lib/capybara/node/actions.rb +31 -28
  7. data/lib/capybara/node/base.rb +2 -1
  8. data/lib/capybara/node/document_matchers.rb +6 -2
  9. data/lib/capybara/node/element.rb +10 -10
  10. data/lib/capybara/node/finders.rb +13 -14
  11. data/lib/capybara/node/matchers.rb +1 -3
  12. data/lib/capybara/node/simple.rb +10 -2
  13. data/lib/capybara/queries/base_query.rb +7 -3
  14. data/lib/capybara/queries/selector_query.rb +60 -34
  15. data/lib/capybara/queries/style_query.rb +5 -1
  16. data/lib/capybara/queries/text_query.rb +2 -2
  17. data/lib/capybara/queries/title_query.rb +1 -1
  18. data/lib/capybara/rack_test/node.rb +16 -2
  19. data/lib/capybara/result.rb +9 -4
  20. data/lib/capybara/rspec/features.rb +4 -4
  21. data/lib/capybara/rspec/matcher_proxies.rb +3 -1
  22. data/lib/capybara/rspec/matchers.rb +25 -287
  23. data/lib/capybara/rspec/matchers/base.rb +98 -0
  24. data/lib/capybara/rspec/matchers/become_closed.rb +33 -0
  25. data/lib/capybara/rspec/matchers/compound.rb +88 -0
  26. data/lib/capybara/rspec/matchers/have_current_path.rb +29 -0
  27. data/lib/capybara/rspec/matchers/have_selector.rb +69 -0
  28. data/lib/capybara/rspec/matchers/have_style.rb +23 -0
  29. data/lib/capybara/rspec/matchers/have_text.rb +33 -0
  30. data/lib/capybara/rspec/matchers/have_title.rb +29 -0
  31. data/lib/capybara/rspec/matchers/match_selector.rb +27 -0
  32. data/lib/capybara/selector.rb +48 -20
  33. data/lib/capybara/selector/builders/xpath_builder.rb +3 -3
  34. data/lib/capybara/selector/css.rb +5 -5
  35. data/lib/capybara/selector/filters/base.rb +11 -3
  36. data/lib/capybara/selector/filters/expression_filter.rb +3 -3
  37. data/lib/capybara/selector/filters/node_filter.rb +16 -2
  38. data/lib/capybara/selector/regexp_disassembler.rb +116 -17
  39. data/lib/capybara/selector/selector.rb +52 -26
  40. data/lib/capybara/selenium/driver.rb +6 -2
  41. data/lib/capybara/selenium/node.rb +15 -14
  42. data/lib/capybara/selenium/nodes/marionette_node.rb +19 -5
  43. data/lib/capybara/selenium/patches/pause_duration_fix.rb +1 -3
  44. data/lib/capybara/server.rb +6 -1
  45. data/lib/capybara/server/animation_disabler.rb +1 -1
  46. data/lib/capybara/session.rb +4 -2
  47. data/lib/capybara/session/matchers.rb +7 -3
  48. data/lib/capybara/spec/public/test.js +5 -5
  49. data/lib/capybara/spec/session/all_spec.rb +5 -0
  50. data/lib/capybara/spec/session/has_css_spec.rb +4 -4
  51. data/lib/capybara/spec/session/has_field_spec.rb +17 -0
  52. data/lib/capybara/spec/session/node_spec.rb +45 -4
  53. data/lib/capybara/spec/spec_helper.rb +6 -1
  54. data/lib/capybara/spec/views/frame_child.erb +1 -1
  55. data/lib/capybara/spec/views/obscured.erb +44 -0
  56. data/lib/capybara/spec/views/with_html.erb +1 -1
  57. data/lib/capybara/version.rb +1 -1
  58. data/spec/rack_test_spec.rb +15 -0
  59. data/spec/regexp_dissassembler_spec.rb +88 -8
  60. data/spec/selector_spec.rb +3 -0
  61. data/spec/selenium_spec_chrome.rb +9 -15
  62. data/spec/selenium_spec_chrome_remote.rb +3 -2
  63. data/spec/selenium_spec_firefox_remote.rb +6 -2
  64. metadata +54 -3
  65. data/lib/capybara/rspec/compound.rb +0 -86
@@ -225,7 +225,7 @@ class Capybara::Selenium::Driver < Capybara::Driver::Base
225
225
  ::Selenium::WebDriver::Error::StaleElementReferenceError,
226
226
  ::Selenium::WebDriver::Error::UnhandledError,
227
227
  ::Selenium::WebDriver::Error::ElementNotVisibleError,
228
- ::Selenium::WebDriver::Error::InvalidSelectorError, # Work around a race condition that can occur with chromedriver and #go_back/#go_forward
228
+ ::Selenium::WebDriver::Error::InvalidSelectorError, # Work around a chromedriver go_back/go_forward race condition
229
229
  ::Selenium::WebDriver::Error::ElementNotInteractableError,
230
230
  ::Selenium::WebDriver::Error::ElementClickInterceptedError,
231
231
  ::Selenium::WebDriver::Error::InvalidElementStateError,
@@ -352,11 +352,15 @@ private
352
352
  when :chrome
353
353
  extend ChromeDriver
354
354
  when :firefox
355
- require 'capybara/selenium/patches/pause_duration_fix' if sel_driver.capabilities['moz:geckodriverVersion']&.start_with?('0.22.')
355
+ require 'capybara/selenium/patches/pause_duration_fix' if pause_broken?(sel_driver)
356
356
  extend MarionetteDriver if sel_driver.capabilities.is_a?(::Selenium::WebDriver::Remote::W3C::Capabilities)
357
357
  end
358
358
  end
359
359
 
360
+ def pause_broken?(driver)
361
+ driver.capabilities['moz:geckodriverVersion']&.start_with?('0.22.')
362
+ end
363
+
360
364
  def setup_exit_handler
361
365
  main = Process.pid
362
366
  at_exit do
@@ -170,22 +170,9 @@ class Capybara::Selenium::Node < Capybara::Driver::Node
170
170
  selector = node[:tagName]
171
171
  if node[:namespaceURI] != default_ns
172
172
  selector = XPath.child.where((XPath.local_name == selector) & (XPath.namespace_uri == node[:namespaceURI])).to_s
173
- selector
174
173
  end
175
174
 
176
- if parent
177
- siblings = parent.find_xpath(selector)
178
- selector += case siblings.size
179
- when 0
180
- '[ERROR]' # IE doesn't support full XPath (namespace-uri, etc)
181
- when 1
182
- '' # index not necessary when only one matching element
183
- else
184
- idx = siblings.index(node)
185
- # Element may not be found in the siblings if it has gone away
186
- idx.nil? ? '[ERROR]' : "[#{idx + 1}]"
187
- end
188
- end
175
+ selector += sibling_index(parent, node, selector) if parent
189
176
  result.push selector
190
177
  end
191
178
 
@@ -203,6 +190,20 @@ protected
203
190
 
204
191
  private
205
192
 
193
+ def sibling_index(parent, node, selector)
194
+ siblings = parent.find_xpath(selector)
195
+ case siblings.size
196
+ when 0
197
+ '[ERROR]' # IE doesn't support full XPath (namespace-uri, etc)
198
+ when 1
199
+ '' # index not necessary when only one matching element
200
+ else
201
+ idx = siblings.index(node)
202
+ # Element may not be found in the siblings if it has gone away
203
+ idx.nil? ? '[ERROR]' : "[#{idx + 1}]"
204
+ end
205
+ end
206
+
206
207
  def boolean_attr(val)
207
208
  val && (val != 'false')
208
209
  end
@@ -9,8 +9,9 @@ class Capybara::Selenium::MarionetteNode < Capybara::Selenium::Node
9
9
  super
10
10
  rescue ::Selenium::WebDriver::Error::ElementNotInteractableError
11
11
  if tag_name == 'tr'
12
- warn 'You are attempting to click a table row which has issues in geckodriver/marionette - see https://github.com/mozilla/geckodriver/issues/1228. ' \
13
- 'Your test should probably be clicking on a table cell like a user would. Clicking the first cell in the row instead.'
12
+ warn 'You are attempting to click a table row which has issues in geckodriver/marionette - '\
13
+ 'see https://github.com/mozilla/geckodriver/issues/1228. Your test should probably be '\
14
+ 'clicking on a table cell like a user would. Clicking the first cell in the row instead.'
14
15
  return find_css('th:first-child,td:first-child')[0].click(keys, options)
15
16
  end
16
17
  raise
@@ -26,7 +27,7 @@ class Capybara::Selenium::MarionetteNode < Capybara::Selenium::Node
26
27
  if %w[option optgroup].include? tag_name
27
28
  find_xpath('parent::*[self::optgroup or self::select]')[0].disabled?
28
29
  else
29
- !find_xpath('parent::fieldset[@disabled] | ancestor::*[not(self::legend) or preceding-sibling::legend][parent::fieldset[@disabled]]').empty?
30
+ !find_xpath(DISABLED_BY_FIELDSET_XPATH).empty?
30
31
  end
31
32
  end
32
33
 
@@ -99,14 +100,27 @@ private
99
100
  return nil unless local_file
100
101
  raise ArgumentError, "You may only upload files: #{local_file.inspect}" unless File.file?(local_file)
101
102
 
102
- result = bridge.http.call(:post, "session/#{bridge.session_id}/file", file: Selenium::WebDriver::Zipper.zip_file(local_file))
103
- result['value']
103
+ file = ::Selenium::WebDriver::Zipper.zip_file(local_file)
104
+ bridge.http.call(:post, "session/#{bridge.session_id}/file", file: file)['value']
104
105
  end
105
106
 
106
107
  def browser_version
107
108
  driver.browser.capabilities[:browser_version].to_f
108
109
  end
109
110
 
111
+ DISABLED_BY_FIELDSET_XPATH = XPath.generate do |x|
112
+ x.parent(:fieldset)[
113
+ x.attr(:disabled)
114
+ ] + x.ancestor[
115
+ ~x.self(:legned) |
116
+ x.preceding_sibling(:legend)
117
+ ][
118
+ x.parent(:fieldset)[
119
+ x.attr(:disabled)
120
+ ]
121
+ ]
122
+ end.to_s.freeze
123
+
110
124
  class ModifierKeysStack
111
125
  def initialize
112
126
  @stack = []
@@ -6,6 +6,4 @@ module PauseDurationFix
6
6
  end
7
7
  end
8
8
 
9
- if defined?(::Selenium::WebDriver::Interactions::Pause)
10
- ::Selenium::WebDriver::Interactions::Pause.prepend PauseDurationFix
11
- end
9
+ ::Selenium::WebDriver::Interactions::Pause.prepend PauseDurationFix
@@ -18,7 +18,12 @@ module Capybara
18
18
 
19
19
  attr_reader :app, :port, :host
20
20
 
21
- def initialize(app, *deprecated_options, port: Capybara.server_port, host: Capybara.server_host, reportable_errors: Capybara.server_errors, extra_middleware: [])
21
+ def initialize(app,
22
+ *deprecated_options,
23
+ port: Capybara.server_port,
24
+ host: Capybara.server_host,
25
+ reportable_errors: Capybara.server_errors,
26
+ extra_middleware: [])
22
27
  warn 'Positional arguments, other than the application, to Server#new are deprecated, please use keyword arguments' unless deprecated_options.empty?
23
28
  @app = app
24
29
  @extra_middleware = extra_middleware
@@ -16,7 +16,7 @@ module Capybara
16
16
 
17
17
  def initialize(app)
18
18
  @app = app
19
- @disable_markup = format(DISABLE_MARKUP_TEMPLATE, selector: AnimationDisabler.selector_for(Capybara.disable_animation))
19
+ @disable_markup = format(DISABLE_MARKUP_TEMPLATE, selector: self.class.selector_for(Capybara.disable_animation))
20
20
  end
21
21
 
22
22
  def call(env)
@@ -43,7 +43,7 @@ module Capybara
43
43
  click_link_or_button click_button click_link
44
44
  fill_in find find_all find_button find_by_id find_field find_link
45
45
  has_content? has_text? has_css? has_no_content? has_no_text?
46
- has_no_css? has_no_xpath? resolve has_xpath? select uncheck
46
+ has_no_css? has_no_xpath? has_xpath? select uncheck
47
47
  has_link? has_no_link? has_button? has_no_button? has_field?
48
48
  has_no_field? has_checked_field? has_unchecked_field?
49
49
  has_no_table? has_table? unselect has_select? has_no_select?
@@ -819,7 +819,9 @@ module Capybara
819
819
  end
820
820
 
821
821
  def prepare_path(path, extension)
822
- File.expand_path(path || default_fn(extension), config.save_path).tap { |p_path| FileUtils.mkdir_p(File.dirname(p_path)) }
822
+ File.expand_path(path || default_fn(extension), config.save_path).tap do |p_path|
823
+ FileUtils.mkdir_p(File.dirname(p_path))
824
+ end
823
825
  end
824
826
 
825
827
  def default_fn(extension)
@@ -6,7 +6,7 @@ module Capybara
6
6
  # Asserts that the page has the given path.
7
7
  # By default, if passed a full url this will compare against the full url,
8
8
  # if passed a path only the path+query portion will be compared, if passed a regexp
9
- # the comparison will depend on the :url option
9
+ # the comparison will depend on the :url option (path+query by default)
10
10
  #
11
11
  # @!macro current_path_query_params
12
12
  # @overload $0(string, **options)
@@ -20,7 +20,9 @@ module Capybara
20
20
  # @return [true]
21
21
  #
22
22
  def assert_current_path(path, **options)
23
- _verify_current_path(path, options) { |query| raise Capybara::ExpectationNotMet, query.failure_message unless query.resolves_for?(self) }
23
+ _verify_current_path(path, options) do |query|
24
+ raise Capybara::ExpectationNotMet, query.failure_message unless query.resolves_for?(self)
25
+ end
24
26
  end
25
27
 
26
28
  ##
@@ -34,7 +36,9 @@ module Capybara
34
36
  # @return [true]
35
37
  #
36
38
  def assert_no_current_path(path, **options)
37
- _verify_current_path(path, options) { |query| raise Capybara::ExpectationNotMet, query.negative_failure_message if query.resolves_for?(self) }
39
+ _verify_current_path(path, options) do |query|
40
+ raise Capybara::ExpectationNotMet, query.negative_failure_message if query.resolves_for?(self)
41
+ end
38
42
  end
39
43
 
40
44
  ##
@@ -159,25 +159,25 @@ $(function() {
159
159
  $(this).attr('confirmed', 'false');
160
160
  }
161
161
  }
162
- })
162
+ });
163
163
  $('#delayed-page-change').click(function() {
164
164
  setTimeout(function() {
165
165
  window.location.pathname = '/with_html'
166
166
  }, 500)
167
- })
167
+ });
168
168
  $('#with-key-events').keydown(function(e){
169
169
  $('#key-events-output').append('keydown:'+e.which+' ')
170
170
  });
171
171
  $('#disable-on-click').click(function(e){
172
- var input = this
172
+ var input = this;
173
173
  setTimeout(function() {
174
174
  input.disabled = true;
175
175
  }, 500)
176
- })
176
+ });
177
177
  $('#set-storage').click(function(e){
178
178
  sessionStorage.setItem('session', 'session_value');
179
179
  localStorage.setItem('local', 'local value');
180
- })
180
+ });
181
181
  $('#multiple-file').change(function(e){
182
182
  $('body').append($('<p class="file_change"input_event_triggered">File input changed</p>'));
183
183
  })
@@ -56,6 +56,11 @@ Capybara::SpecHelper.spec '#all' do
56
56
  expect(@session.all(:xpath, '//h1').first.text).to eq('This is a test')
57
57
  expect(@session.all(:xpath, "//input[@id='test_field']").first.value).to eq('monkey')
58
58
  end
59
+
60
+ it 'should use alternated regex for :id' do
61
+ expect(@session.all(:xpath, './/h2', id: /h2/).unfiltered_size).to eq 3
62
+ expect(@session.all(:xpath, './/h2', id: /h2(one|two)/).unfiltered_size).to eq 2
63
+ end
59
64
  end
60
65
 
61
66
  context 'with css as default selector' do
@@ -50,17 +50,17 @@ Capybara::SpecHelper.spec '#has_css?' do
50
50
  it 'should be able to generate an error message if the scope is a sibling' do
51
51
  el = @session.find(:css, '#first')
52
52
  @session.within el.sibling(:css, '#second') do
53
- expect {
53
+ expect do
54
54
  expect(@session).to have_css('a#not_on_page')
55
- }.to raise_error /there were no matches/
55
+ end.to raise_error(/there were no matches/)
56
56
  end
57
57
  end
58
58
 
59
59
  it 'should be able to generate an error message if the scope is a sibling from XPath' do
60
60
  el = @session.find(:css, '#first').find(:xpath, './following-sibling::*[1]') do
61
- expect {
61
+ expect do
62
62
  expect(el).to have_css('a#not_on_page')
63
- }.to raise_error /there were no matches/
63
+ end.to raise_error(/there were no matches/)
64
64
  end
65
65
  end
66
66
  end
@@ -41,6 +41,23 @@ Capybara::SpecHelper.spec '#has_field' do
41
41
  expect(@session).not_to have_field('First Name', with: 'John')
42
42
  expect(@session).not_to have_field('First Name', with: /John|Paul|George|Ringo/)
43
43
  end
44
+
45
+ it 'should output filter errors if only one element matched the selector but failed the filters' do
46
+ @session.fill_in('First Name', with: 'Thomas')
47
+ expect do
48
+ expect(@session).to have_field('First Name', with: 'Jonas')
49
+ end.to raise_exception(RSpec::Expectations::ExpectationNotMetError, /Expected value to be "Jonas" but was "Thomas"/)
50
+
51
+ # native boolean node filter
52
+ expect do
53
+ expect(@session).to have_field('First Name', readonly: true)
54
+ end.to raise_exception(RSpec::Expectations::ExpectationNotMetError, /Expected readonly true but it wasn't/)
55
+
56
+ # inherited boolean node filter
57
+ expect do
58
+ expect(@session).to have_field('form_pets_cat', checked: true)
59
+ end.to raise_exception(RSpec::Expectations::ExpectationNotMetError, /Expected checked true but it wasn't/)
60
+ end
44
61
  end
45
62
 
46
63
  context 'with type' do
@@ -411,6 +411,33 @@ Capybara::SpecHelper.spec 'node' do
411
411
  tr = @session.find(:css, '#agent_table tr:first-child').click
412
412
  expect(tr).to have_css('label', text: 'Clicked')
413
413
  end
414
+
415
+ it 'should retry clicking', requires: [:js] do
416
+ @session.visit('/obscured')
417
+ obscured = @session.find(:css, '#obscured')
418
+ @session.execute_script <<~JS
419
+ setTimeout(function(){ $('#cover').hide(); }, 1000)
420
+ JS
421
+ expect { obscured.click }.not_to raise_error
422
+ end
423
+
424
+ it 'should allow to retry longer', requires: [:js] do
425
+ @session.visit('/obscured')
426
+ obscured = @session.find(:css, '#obscured')
427
+ @session.execute_script <<~JS
428
+ setTimeout(function(){ $('#cover').hide(); }, 3000)
429
+ JS
430
+ expect { obscured.click(wait: 4) }.not_to raise_error
431
+ end
432
+
433
+ it 'should not retry clicking when wait is disabled', requires: [:js] do
434
+ @session.visit('/obscured')
435
+ obscured = @session.find(:css, '#obscured')
436
+ @session.execute_script <<~JS
437
+ setTimeout(function(){ $('#cover').hide(); }, 2000)
438
+ JS
439
+ expect { obscured.click(wait: 0) }.to(raise_error { |e| expect(e).to be_an_invalid_element_error(@session) })
440
+ end
414
441
  end
415
442
 
416
443
  describe '#double_click', requires: [:js] do
@@ -436,6 +463,15 @@ Capybara::SpecHelper.spec 'node' do
436
463
  expect(locations[:x].to_f).to be_within(1).of(10)
437
464
  expect(locations[:y].to_f).to be_within(1).of(5)
438
465
  end
466
+
467
+ it 'should retry clicking', requires: [:js] do
468
+ @session.visit('/obscured')
469
+ obscured = @session.find(:css, '#obscured')
470
+ @session.execute_script <<~JS
471
+ setTimeout(function(){ $('#cover').hide(); }, 1000)
472
+ JS
473
+ expect { obscured.double_click }.not_to raise_error
474
+ end
439
475
  end
440
476
 
441
477
  describe '#right_click', requires: [:js] do
@@ -461,6 +497,15 @@ Capybara::SpecHelper.spec 'node' do
461
497
  expect(locations[:x].to_f).to be_within(1).of(10)
462
498
  expect(locations[:y].to_f).to be_within(1).of(10)
463
499
  end
500
+
501
+ it 'should retry clicking', requires: [:js] do
502
+ @session.visit('/obscured')
503
+ obscured = @session.find(:css, '#obscured')
504
+ @session.execute_script <<~JS
505
+ setTimeout(function(){ $('#cover').hide(); }, 1000)
506
+ JS
507
+ expect { obscured.right_click }.not_to raise_error
508
+ end
464
509
  end
465
510
 
466
511
  describe '#send_keys', requires: [:send_keys] do
@@ -670,8 +715,4 @@ Capybara::SpecHelper.spec 'node' do
670
715
  end)
671
716
  end
672
717
  end
673
-
674
- def be_an_invalid_element_error(session)
675
- satisfy { |error| session.driver.invalid_element_errors.any? { |e| error.is_a? e } }
676
- end
677
718
  end
@@ -117,7 +117,12 @@ module Capybara
117
117
 
118
118
  def extract_results(session)
119
119
  expect(session).to have_xpath("//pre[@id='results']")
120
- YAML.load Nokogiri::HTML(session.body).xpath("//pre[@id='results']").first.inner_html.lstrip
120
+ # YAML.load Nokogiri::HTML(session.body).xpath("//pre[@id='results']").first.inner_html.lstrip
121
+ YAML.load Capybara::HTML(session.body).xpath("//pre[@id='results']").first.inner_html.lstrip
122
+ end
123
+
124
+ def be_an_invalid_element_error(session)
125
+ satisfy { |error| session.driver.invalid_element_errors.any? { |e| error.is_a? e } }
121
126
  end
122
127
  end
123
128
  end
@@ -4,7 +4,7 @@
4
4
  <title>This is the child frame title</title>
5
5
  <script>
6
6
  function closeWin() {
7
- var iframe = window.parent.document.getElementById('childFrame')
7
+ var iframe = window.parent.document.getElementById('childFrame');
8
8
  iframe.parentNode.removeChild(iframe)
9
9
  }
10
10
  </script>
@@ -0,0 +1,44 @@
1
+ <html>
2
+ <head>
3
+ <meta http-equiv="Content-type" content="text/html; charset=utf-8"/>
4
+ <title>with_animation</title>
5
+ <script src="/jquery.js" type="text/javascript" charset="utf-8"></script>
6
+ <style>
7
+ div {
8
+ width: 400px;
9
+ height: 400px;
10
+ position: absolute;
11
+ }
12
+ #obscured {
13
+ z-index: 1;
14
+ background-color: red;
15
+ }
16
+ #cover {
17
+ z-index: 2;
18
+ background-color: blue;
19
+ }
20
+ #offscreen {
21
+ top: 2000px;
22
+ left: 2000px;
23
+ background-color: green;
24
+ }
25
+ #offscreen_wrapper {
26
+ top: 2000px;
27
+ left: 2000px;
28
+ overflow-x: scroll;
29
+ background-color: yellow;
30
+ }
31
+ </style>
32
+ </head>
33
+
34
+ <body id="with_animation">
35
+ <div id="obscured">
36
+ <input id="obscured_input"/>
37
+ </div>
38
+ <div id="cover"></div>
39
+ <div id="offscreen_wrapper">
40
+ <div id="offscreen"></div>
41
+ </div>
42
+ </body>
43
+ </html>
44
+
@@ -10,7 +10,7 @@
10
10
  <h2 class="no text"></h2>
11
11
  <h2 class="head" id="h2one">Header Class Test One</h2>
12
12
  <h2 class="head" id="h2two">Header Class Test Two</h2>
13
- <h2 class="head">Header Class Test Three</h2>
13
+ <h2 class="head" id="h2_">Header Class Test Three</h2>
14
14
  <h2 class="head">Header Class Test Four</h2>
15
15
  <h2 class="head">Header Class Test Five</h2>
16
16