capybara 3.19.1 → 3.20.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/History.md +18 -0
  3. data/README.md +1 -1
  4. data/lib/capybara/driver/node.rb +4 -0
  5. data/lib/capybara/node/actions.rb +3 -2
  6. data/lib/capybara/node/element.rb +11 -0
  7. data/lib/capybara/node/finders.rb +4 -1
  8. data/lib/capybara/queries/selector_query.rb +19 -5
  9. data/lib/capybara/selector.rb +2 -2
  10. data/lib/capybara/selector/definition/label.rb +27 -10
  11. data/lib/capybara/selector/definition/link.rb +3 -2
  12. data/lib/capybara/selenium/atoms/getAttribute.min.js +1 -0
  13. data/lib/capybara/selenium/atoms/isDisplayed.min.js +1 -0
  14. data/lib/capybara/selenium/atoms/src/getAttribute.js +161 -0
  15. data/lib/capybara/selenium/atoms/src/isDisplayed.js +454 -0
  16. data/lib/capybara/selenium/driver.rb +14 -1
  17. data/lib/capybara/selenium/driver_specializations/firefox_driver.rb +1 -1
  18. data/lib/capybara/selenium/driver_specializations/internet_explorer_driver.rb +1 -1
  19. data/lib/capybara/selenium/driver_specializations/safari_driver.rb +1 -1
  20. data/lib/capybara/selenium/node.rb +25 -1
  21. data/lib/capybara/selenium/nodes/safari_node.rb +19 -6
  22. data/lib/capybara/selenium/patches/atoms.rb +18 -0
  23. data/lib/capybara/spec/session/all_spec.rb +23 -0
  24. data/lib/capybara/spec/session/click_link_spec.rb +11 -0
  25. data/lib/capybara/spec/session/node_spec.rb +78 -5
  26. data/lib/capybara/spec/session/selectors_spec.rb +8 -0
  27. data/lib/capybara/spec/views/animated.erb +49 -0
  28. data/lib/capybara/spec/views/frame_one.erb +1 -0
  29. data/lib/capybara/spec/views/obscured.erb +9 -9
  30. data/lib/capybara/version.rb +1 -1
  31. data/spec/sauce_spec_chrome.rb +1 -0
  32. data/spec/selenium_spec_chrome.rb +4 -2
  33. data/spec/selenium_spec_chrome_remote.rb +4 -2
  34. data/spec/selenium_spec_edge.rb +4 -2
  35. data/spec/selenium_spec_firefox.rb +4 -11
  36. data/spec/selenium_spec_firefox_remote.rb +4 -2
  37. data/spec/selenium_spec_ie.rb +5 -6
  38. data/spec/selenium_spec_safari.rb +7 -12
  39. data/spec/server_spec.rb +4 -2
  40. data/spec/shared_selenium_node.rb +29 -0
  41. metadata +23 -2
@@ -18,6 +18,7 @@ class Capybara::Selenium::Driver < Capybara::Driver::Base
18
18
  def load_selenium
19
19
  require 'selenium-webdriver'
20
20
  require 'capybara/selenium/logger_suppressor'
21
+ require 'capybara/selenium/patches/atoms'
21
22
  warn "Warning: You're using an unsupported version of selenium-webdriver, please upgrade." if Gem.loaded_specs['selenium-webdriver'].version < Gem::Version.new('3.5.0')
22
23
  rescue LoadError => e
23
24
  raise e unless e.message.match?(/selenium-webdriver/)
@@ -135,6 +136,18 @@ class Capybara::Selenium::Driver < Capybara::Driver::Base
135
136
  end
136
137
  end
137
138
 
139
+ def frame_obscured_at?(x:, y:)
140
+ frame = @frame_handles[current_window_handle].last
141
+ return false unless frame
142
+
143
+ switch_to_frame(:parent)
144
+ begin
145
+ return frame.base.obscured?(x: x, y: y)
146
+ ensure
147
+ switch_to_frame(frame)
148
+ end
149
+ end
150
+
138
151
  def switch_to_frame(frame)
139
152
  handles = @frame_handles[current_window_handle]
140
153
  case frame
@@ -145,7 +158,7 @@ class Capybara::Selenium::Driver < Capybara::Driver::Base
145
158
  handles.pop
146
159
  browser.switch_to.parent_frame
147
160
  else
148
- handles << frame.native
161
+ handles << frame
149
162
  browser.switch_to.frame(frame.native)
150
163
  end
151
164
  end
@@ -60,7 +60,7 @@ module Capybara::Selenium::Driver::W3CFirefoxDriver
60
60
  # so we have to move to the default_content and iterate back through the frames
61
61
  handles = @frame_handles[current_window_handle]
62
62
  browser.switch_to.default_content
63
- handles.tap(&:pop).each { |fh| browser.switch_to.frame(fh) }
63
+ handles.tap(&:pop).each { |fh| browser.switch_to.frame(fh.native) }
64
64
  end
65
65
 
66
66
  private
@@ -10,7 +10,7 @@ module Capybara::Selenium::Driver::InternetExplorerDriver
10
10
  # so we have to move to the default_content and iterate back through the frames
11
11
  handles = @frame_handles[current_window_handle]
12
12
  browser.switch_to.default_content
13
- handles.tap(&:pop).each { |fh| browser.switch_to.frame(fh) }
13
+ handles.tap(&:pop).each { |fh| browser.switch_to.frame(fh.native) }
14
14
  end
15
15
 
16
16
  private
@@ -10,7 +10,7 @@ module Capybara::Selenium::Driver::SafariDriver
10
10
  # behaves like switch_to_frame(:top)
11
11
  handles = @frame_handles[current_window_handle]
12
12
  browser.switch_to.default_content
13
- handles.tap(&:pop).each { |fh| browser.switch_to.frame(fh) }
13
+ handles.tap(&:pop).each { |fh| browser.switch_to.frame(fh.native) }
14
14
  end
15
15
 
16
16
  private
@@ -155,7 +155,7 @@ class Capybara::Selenium::Node < Capybara::Driver::Node
155
155
  end
156
156
 
157
157
  def content_editable?
158
- native.attribute('isContentEditable')
158
+ native.attribute('isContentEditable') == 'true'
159
159
  end
160
160
 
161
161
  def ==(other)
@@ -166,6 +166,13 @@ class Capybara::Selenium::Node < Capybara::Driver::Node
166
166
  driver.evaluate_script GET_XPATH_SCRIPT, self
167
167
  end
168
168
 
169
+ def obscured?(x: nil, y: nil)
170
+ res = driver.evaluate_script(OBSCURED_OR_OFFSET_SCRIPT, self, x, y)
171
+ return true if res == true
172
+
173
+ driver.frame_obscured_at?(x: res['x'], y: res['y'])
174
+ end
175
+
169
176
  protected
170
177
 
171
178
  def scroll_if_needed
@@ -401,6 +408,23 @@ private
401
408
  })(arguments[0], document)
402
409
  JS
403
410
 
411
+ OBSCURED_OR_OFFSET_SCRIPT = <<~'JS'
412
+ (function(el, x, y) {
413
+ var box = el.getBoundingClientRect();
414
+ if (x == null) x = box.width/2;
415
+ if (y == null) y = box.height/2 ;
416
+
417
+ var px = box.left + x,
418
+ py = box.top + y,
419
+ e = document.elementFromPoint(px, py);
420
+
421
+ if (!el.contains(e))
422
+ return true;
423
+
424
+ return { x: px, y: py };
425
+ })(arguments[0], arguments[1], arguments[2])
426
+ JS
427
+
404
428
  # SettableValue encapsulates time/date field formatting
405
429
  class SettableValue
406
430
  attr_reader :value
@@ -16,6 +16,10 @@ class Capybara::Selenium::SafariNode < Capybara::Selenium::Node
16
16
  return find_css('th:first-child,td:first-child')[0].click(keys, options)
17
17
  end
18
18
  raise
19
+ rescue ::Selenium::WebDriver::Error::WebDriverError
20
+ # Safari doesn't return a specific error here - assume it's an ElementNotInteractableError
21
+ raise ::Selenium::WebDriver::Error::ElementNotInteractableError,
22
+ 'Non distinct error raised in #click, translated to ElementNotInteractableError for retry'
19
23
  end
20
24
 
21
25
  def select_option
@@ -54,7 +58,9 @@ class Capybara::Selenium::SafariNode < Capybara::Selenium::Node
54
58
  end
55
59
 
56
60
  def send_keys(*args)
57
- return super(*args.map { |arg| arg == :space ? ' ' : arg }) if args.none? { |arg| arg.is_a? Array }
61
+ if args.none? { |arg| arg.is_a?(Array) || (arg.is_a?(Symbol) && MODIFIER_KEYS.include?(arg)) }
62
+ return super(*args.map { |arg| arg == :space ? ' ' : arg })
63
+ end
58
64
 
59
65
  native.click
60
66
  _send_keys(args).perform
@@ -74,6 +80,11 @@ class Capybara::Selenium::SafariNode < Capybara::Selenium::Node
74
80
  end
75
81
  end
76
82
 
83
+ def hover
84
+ # Workaround issue where hover would sometimes fail - possibly due to mouse not having moved
85
+ scroll_if_needed { browser_action.move_to(native, 0, 0).move_to(native).perform }
86
+ end
87
+
77
88
  private
78
89
 
79
90
  def bridge
@@ -95,11 +106,7 @@ private
95
106
 
96
107
  def _send_keys(keys, actions = browser_action, down_keys = ModifierKeysStack.new)
97
108
  case keys
98
- when :control, :left_control, :right_control,
99
- :alt, :left_alt, :right_alt,
100
- :shift, :left_shift, :right_shift,
101
- :meta, :left_meta, :right_meta,
102
- :command
109
+ when *MODIFIER_KEYS
103
110
  down_keys.press(keys)
104
111
  actions.key_down(keys)
105
112
  when String
@@ -117,6 +124,12 @@ private
117
124
  actions
118
125
  end
119
126
 
127
+ MODIFIER_KEYS = %i[control left_control right_control
128
+ alt left_alt right_alt
129
+ shift left_shift right_shift
130
+ meta left_meta right_meta
131
+ command].freeze
132
+
120
133
  class ModifierKeysStack
121
134
  def initialize
122
135
  @stack = []
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CapybaraAtoms
4
+ private # rubocop:disable Layout/IndentationWidth
5
+
6
+ def read_atom(function)
7
+ @atoms ||= Hash.new do |hash, key|
8
+ hash[key] = begin
9
+ File.read(File.expand_path("../../atoms/#{key}.min.js", __FILE__))
10
+ rescue Errno::ENOENT
11
+ super
12
+ end
13
+ end
14
+ @atoms[function]
15
+ end
16
+ end
17
+
18
+ ::Selenium::WebDriver::Remote::Bridge.prepend CapybaraAtoms unless ENV['DISABLE_CAPYBARA_SELENIUM_OPTIMIZATIONS']
@@ -100,6 +100,29 @@ Capybara::SpecHelper.spec '#all' do
100
100
  end
101
101
  end
102
102
 
103
+ context 'with obscured filter', requires: [:css] do
104
+ it 'should only find nodes on top in the viewport when false' do
105
+ expect(@session.all(:css, 'a.simple', obscured: false).size).to eq(1)
106
+ end
107
+
108
+ it 'should not find nodes on top outside the viewport when false' do
109
+ expect(@session.all(:link, 'Download Me', obscured: false).size).to eq(0)
110
+ @session.scroll_to(@session.find_link('Download Me'))
111
+ expect(@session.all(:link, 'Download Me', obscured: false).size).to eq(1)
112
+ end
113
+
114
+ it 'should find top nodes outside the viewport when true' do
115
+ expect(@session.all(:link, 'Download Me', obscured: true).size).to eq(1)
116
+ @session.scroll_to(@session.find_link('Download Me'))
117
+ expect(@session.all(:link, 'Download Me', obscured: true).size).to eq(0)
118
+ end
119
+
120
+ it 'should only find non-top nodes when true' do
121
+ # Also need visible: false so visibility is ignored
122
+ expect(@session.all(:css, 'a.simple', visible: false, obscured: true).size).to eq(1)
123
+ end
124
+ end
125
+
103
126
  context 'with element count filters' do
104
127
  context ':count' do
105
128
  it 'should succeed when the number of elements founds matches the expectation' do
@@ -118,6 +118,17 @@ Capybara::SpecHelper.spec '#click_link' do
118
118
  expect { @session.click_link('Normal Anchor', href: nil) }.to raise_error(Capybara::ElementNotFound, /with no href attribute/)
119
119
  end
120
120
  end
121
+
122
+ context 'href: false' do
123
+ it 'should not raise an error on links with no href attribute' do
124
+ expect { @session.click_link('No Href', href: false) }.not_to raise_error
125
+ end
126
+
127
+ it 'should not raise an error if href attribute exists' do
128
+ expect { @session.click_link('Blank Href', href: false) }.not_to raise_error
129
+ expect { @session.click_link('Normal Anchor', href: false) }.not_to raise_error
130
+ end
131
+ end
121
132
  end
122
133
 
123
134
  it 'should follow relative links' do
@@ -251,6 +251,79 @@ Capybara::SpecHelper.spec 'node' do
251
251
  end
252
252
  end
253
253
 
254
+ describe '#obscured?', requires: [:css] do
255
+ it 'should see non visible elements as obscured' do
256
+ Capybara.ignore_hidden_elements = false
257
+ expect(@session.find('//div[@id="hidden"]')).to be_obscured
258
+ expect(@session.find('//div[@id="hidden_via_ancestor"]')).to be_obscured
259
+ expect(@session.find('//div[@id="hidden_attr"]')).to be_obscured
260
+ expect(@session.find('//a[@id="hidden_attr_via_ancestor"]')).to be_obscured
261
+ expect(@session.find('//input[@id="hidden_input"]')).to be_obscured
262
+ end
263
+
264
+ it 'should see non-overlapped elements as not obscured' do
265
+ @session.visit('/obscured')
266
+ expect(@session.find(:css, '#cover')).not_to be_obscured
267
+ end
268
+
269
+ it 'should see elements only overlapped by descendants as not obscured' do
270
+ expect(@session.first(:css, 'p:not(.para)')).not_to be_obscured
271
+ end
272
+
273
+ it 'should see elements outside the viewport as obscured' do
274
+ @session.visit('/obscured')
275
+ off = @session.find(:css, '#offscreen')
276
+ off_wrapper = @session.find(:css, '#offscreen_wrapper')
277
+ expect(off).to be_obscured
278
+ expect(off_wrapper).to be_obscured
279
+ @session.scroll_to(off_wrapper)
280
+ expect(off_wrapper).not_to be_obscured
281
+ expect(off).to be_obscured
282
+ off_wrapper.scroll_to(off)
283
+ expect(off).not_to be_obscured
284
+ expect(off_wrapper).not_to be_obscured
285
+ end
286
+
287
+ it 'should see overlapped elements as obscured' do
288
+ @session.visit('/obscured')
289
+ expect(@session.find(:css, '#obscured')).to be_obscured
290
+ end
291
+
292
+ it 'should be boolean' do
293
+ Capybara.ignore_hidden_elements = false
294
+ expect(@session.first('//a').obscured?).to be false
295
+ expect(@session.find('//div[@id="hidden"]').obscured?).to be true
296
+ end
297
+
298
+ it 'should work in frames' do
299
+ @session.visit('/obscured')
300
+ frame = @session.find(:css, '#frameOne')
301
+ @session.within_frame(frame) do
302
+ div = @session.find(:css, '#divInFrameOne')
303
+ expect(div).to be_obscured
304
+ @session.scroll_to div
305
+ expect(div).not_to be_obscured
306
+ end
307
+ end
308
+
309
+ it 'should work in nested iframes' do
310
+ @session.visit('/obscured')
311
+ frame = @session.find(:css, '#nestedFrames')
312
+ @session.within_frame(frame) do
313
+ @session.within_frame(:css, '#childFrame') do
314
+ gcframe = @session.find(:css, '#grandchildFrame2')
315
+ @session.within_frame(gcframe) do
316
+ expect(@session.find(:css, '#divInFrameTwo')).to be_obscured
317
+ end
318
+ @session.scroll_to(gcframe)
319
+ @session.within_frame(gcframe) do
320
+ expect(@session.find(:css, '#divInFrameTwo')).not_to be_obscured
321
+ end
322
+ end
323
+ end
324
+ end
325
+ end
326
+
254
327
  describe '#checked?' do
255
328
  it 'should extract node checked state' do
256
329
  @session.visit('/form')
@@ -441,7 +514,7 @@ Capybara::SpecHelper.spec 'node' do
441
514
  end
442
515
 
443
516
  it 'should retry clicking', requires: [:js] do
444
- @session.visit('/obscured')
517
+ @session.visit('/animated')
445
518
  obscured = @session.find(:css, '#obscured')
446
519
  @session.execute_script <<~JS
447
520
  setTimeout(function(){ $('#cover').hide(); }, 700)
@@ -450,7 +523,7 @@ Capybara::SpecHelper.spec 'node' do
450
523
  end
451
524
 
452
525
  it 'should allow to retry longer', requires: [:js] do
453
- @session.visit('/obscured')
526
+ @session.visit('/animated')
454
527
  obscured = @session.find(:css, '#obscured')
455
528
  @session.execute_script <<~JS
456
529
  setTimeout(function(){ $('#cover').hide(); }, 3000)
@@ -459,7 +532,7 @@ Capybara::SpecHelper.spec 'node' do
459
532
  end
460
533
 
461
534
  it 'should not retry clicking when wait is disabled', requires: [:js] do
462
- @session.visit('/obscured')
535
+ @session.visit('/animated')
463
536
  obscured = @session.find(:css, '#obscured')
464
537
  @session.execute_script <<~JS
465
538
  setTimeout(function(){ $('#cover').hide(); }, 2000)
@@ -493,7 +566,7 @@ Capybara::SpecHelper.spec 'node' do
493
566
  end
494
567
 
495
568
  it 'should retry clicking', requires: [:js] do
496
- @session.visit('/obscured')
569
+ @session.visit('/animated')
497
570
  obscured = @session.find(:css, '#obscured')
498
571
  @session.execute_script <<~JS
499
572
  setTimeout(function(){ $('#cover').hide(); }, 700)
@@ -527,7 +600,7 @@ Capybara::SpecHelper.spec 'node' do
527
600
  end
528
601
 
529
602
  it 'should retry clicking', requires: [:js] do
530
- @session.visit('/obscured')
603
+ @session.visit('/animated')
531
604
  obscured = @session.find(:css, '#obscured')
532
605
  @session.execute_script <<~JS
533
606
  setTimeout(function(){ $('#cover').hide(); }, 700)
@@ -14,10 +14,18 @@ Capybara::SpecHelper.spec Capybara::Selector do
14
14
  expect(@session.find(:label, for: 'form_other_title')['for']).to eq 'form_other_title'
15
15
  end
16
16
 
17
+ it 'finds a label for for attribute regex' do
18
+ expect(@session.find(:label, for: /_other_title/)['for']).to eq 'form_other_title'
19
+ end
20
+
17
21
  it 'finds a label from nested input using :for filter with id string' do
18
22
  expect(@session.find(:label, for: 'nested_label').text).to eq 'Nested Label'
19
23
  end
20
24
 
25
+ it 'finds a label from nested input using :for filter with id regexp' do
26
+ expect(@session.find(:label, for: /nested_lab/).text).to eq 'Nested Label'
27
+ end
28
+
21
29
  it 'finds a label from nested input using :for filter with element' do
22
30
  input = @session.find(:id, 'nested_label')
23
31
  expect(@session.find(:label, for: input).text).to eq 'Nested Label'
@@ -0,0 +1,49 @@
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
+ <script>
7
+ $(document).on('contextmenu', function(e){ e.preventDefault(); });
8
+ </script>
9
+ <style>
10
+ div {
11
+ width: 400px;
12
+ height: 400px;
13
+ position: absolute;
14
+ }
15
+ #obscured {
16
+ z-index: 1;
17
+ background-color: red;
18
+ }
19
+ #cover {
20
+ z-index: 2;
21
+ background-color: blue;
22
+ }
23
+ #offscreen {
24
+ top: 2000px;
25
+ left: 2000px;
26
+ background-color: green;
27
+ }
28
+ #offscreen_wrapper {
29
+ top: 2000px;
30
+ left: 2000px;
31
+ overflow-x: scroll;
32
+ background-color: yellow;
33
+ }
34
+ </style>
35
+ </head>
36
+
37
+ <body id="with_animation">
38
+ <div id="obscured">
39
+ <input id="obscured_input"/>
40
+ </div>
41
+ <div id="cover"></div>
42
+ <div id="offscreen_wrapper">
43
+ <div id="offscreen"></div>
44
+ </div>
45
+ </body>
46
+
47
+ <iframe id="frameOne" src="/frame_one"></iframe>
48
+ </html>
49
+
@@ -5,5 +5,6 @@
5
5
  </head>
6
6
  <body>
7
7
  <div id="divInFrameOne">This is the text of divInFrameOne</div>
8
+ <div id="otherDivInFrameOne">Some other text</div>
8
9
  </body>
9
10
  </html>
@@ -1,15 +1,13 @@
1
1
  <html>
2
2
  <head>
3
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
- <script>
7
- $(document).on('contextmenu', function(e){ e.preventDefault(); });
8
- </script>
4
+ <title>Obscured</title>
9
5
  <style>
10
6
  div {
11
- width: 400px;
12
- height: 400px;
7
+ width: 200px;
8
+ height: 200px;
9
+ }
10
+ #cover, #offscreen, #offscreen_wrapper {
13
11
  position: absolute;
14
12
  }
15
13
  #obscured {
@@ -17,6 +15,7 @@
17
15
  background-color: red;
18
16
  }
19
17
  #cover {
18
+ top: 0px;
20
19
  z-index: 2;
21
20
  background-color: blue;
22
21
  }
@@ -33,12 +32,13 @@
33
32
  }
34
33
  </style>
35
34
  </head>
36
-
37
- <body id="with_animation">
35
+ <body>
38
36
  <div id="obscured">
39
37
  <input id="obscured_input"/>
40
38
  </div>
41
39
  <div id="cover"></div>
40
+ <iframe id="frameOne" height="10px" src="/frame_one"></iframe>
41
+ <iframe id="nestedFrames" src="/frame_parent"></iframe>
42
42
  <div id="offscreen_wrapper">
43
43
  <div id="offscreen"></div>
44
44
  </div>