capybara 3.14.0 → 3.15.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/History.md +13 -0
  3. data/README.md +2 -1
  4. data/lib/capybara/node/actions.rb +37 -6
  5. data/lib/capybara/node/matchers.rb +10 -2
  6. data/lib/capybara/selector.rb +117 -1
  7. data/lib/capybara/selector/selector.rb +7 -0
  8. data/lib/capybara/selector/xpath_extensions.rb +9 -0
  9. data/lib/capybara/selenium/driver.rb +13 -2
  10. data/lib/capybara/selenium/driver_specializations/safari_driver.rb +15 -0
  11. data/lib/capybara/selenium/extensions/find.rb +2 -1
  12. data/lib/capybara/selenium/node.rb +1 -1
  13. data/lib/capybara/selenium/nodes/firefox_node.rb +1 -1
  14. data/lib/capybara/selenium/nodes/safari_node.rb +145 -0
  15. data/lib/capybara/spec/session/attach_file_spec.rb +46 -27
  16. data/lib/capybara/spec/session/click_button_spec.rb +65 -60
  17. data/lib/capybara/spec/session/element/matches_selector_spec.rb +40 -39
  18. data/lib/capybara/spec/session/fill_in_spec.rb +3 -3
  19. data/lib/capybara/spec/session/find_spec.rb +5 -0
  20. data/lib/capybara/spec/session/has_table_spec.rb +120 -0
  21. data/lib/capybara/spec/session/node_spec.rb +3 -3
  22. data/lib/capybara/spec/session/reset_session_spec.rb +8 -7
  23. data/lib/capybara/spec/session/window/become_closed_spec.rb +20 -17
  24. data/lib/capybara/spec/session/window/window_spec.rb +44 -48
  25. data/lib/capybara/spec/views/form.erb +5 -0
  26. data/lib/capybara/spec/views/tables.erb +67 -0
  27. data/lib/capybara/spec/views/with_html.erb +2 -2
  28. data/lib/capybara/spec/views/with_js.erb +1 -0
  29. data/lib/capybara/version.rb +1 -1
  30. data/spec/capybara_spec.rb +4 -4
  31. data/spec/css_builder_spec.rb +2 -0
  32. data/spec/dsl_spec.rb +13 -17
  33. data/spec/rack_test_spec.rb +77 -85
  34. data/spec/rspec/features_spec.rb +2 -0
  35. data/spec/rspec/shared_spec_matchers.rb +34 -35
  36. data/spec/rspec_spec.rb +11 -13
  37. data/spec/selector_spec.rb +31 -0
  38. data/spec/selenium_spec_chrome.rb +25 -25
  39. data/spec/selenium_spec_firefox.rb +62 -35
  40. data/spec/selenium_spec_firefox_remote.rb +2 -0
  41. data/spec/selenium_spec_safari.rb +148 -0
  42. data/spec/server_spec.rb +40 -44
  43. data/spec/shared_selenium_session.rb +27 -21
  44. data/spec/spec_helper.rb +4 -0
  45. data/spec/xpath_builder_spec.rb +2 -0
  46. metadata +7 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f79ad67119c7cc637fcab5cbc4fe95b0d80a76c2c38182f2a6b8beb3564f8448
4
- data.tar.gz: 9d5c1ca24bbc3e7f2a9c18c71c5b61a78b96d24147b21c9b0f753217d4fc2772
3
+ metadata.gz: 023614c44b5551f29984d882a7fb13b5ff240f589eff090cfb24c4f3a2787a47
4
+ data.tar.gz: c5c1cd40b59700f743325cca40efbe9fd68df776c7f16f6d351336400e86cdd4
5
5
  SHA512:
6
- metadata.gz: a799dc6db1032d812bf749ca8ee8ca8b46e59e957130f80ddfb9d5c2b08fce1a543ce3a0e9c28ee2bf38470867bc6d8a64ce6ed3f3d6c32ae3f31f41c071cb8f
7
- data.tar.gz: d41beb222470a77bd2f2784a8def99ef3fc4b2928ee1b50cb66167d1764a94ec7ea1be59424c4d09f2a7981b3c6ba47ee6df02b13d6472480f10d9698c742e82
6
+ metadata.gz: a76732bb72e5e5daefaf55a2bc4c895ef56d109905b9b3b2a5c96e229d97d783f1044dd6971ed21d4e63e55200a5e00a4641f50698f79346767fe498a5b894ad
7
+ data.tar.gz: ac8d9499807a093c26ca861ed204087702b1a3713b757c6e9d84d43f399b38db36b3b1ab28471413dfd385b442b7636abe9f16194ae4ef17395a16d5e92b7a1c
data/History.md CHANGED
@@ -1,3 +1,16 @@
1
+ # Version 3.15
2
+ Release date: 2019-03-19
3
+
4
+ ### Added
5
+
6
+ * `attach_file` now supports a block mode on JS capable drivers to more accurately test user behavior when file inputs are hidden (beta)
7
+ * :table selector now supports `with_rows`, 'rows', `with_cols`, and 'cols' filters
8
+
9
+ ### Fixed
10
+
11
+ * Fix link selector when `Capybara.test_id` is set - Issue #2166 [bingjyang]
12
+
13
+
1
14
  # Version 3.14
2
15
  Release date: 2019-02-25
3
16
 
data/README.md CHANGED
@@ -6,7 +6,8 @@
6
6
  [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/jnicklas/capybara?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
7
7
  [![SemVer](https://api.dependabot.com/badges/compatibility_score?dependency-name=capybara&package-manager=bundler&version-scheme=semver)](https://dependabot.com/compatibility-score.html?dependency-name=capybara&package-manager=bundler&version-scheme=semver)
8
8
 
9
- **Note** You are viewing the README for the 3.14.x version of Capybara.
9
+ **Note** You are viewing the README for the 3.15.x version of Capybara.
10
+
10
11
 
11
12
  Capybara helps you test web applications by simulating how a real user would
12
13
  interact with your app. It is agnostic about the driver running your tests and
@@ -231,11 +231,14 @@ module Capybara
231
231
 
232
232
  ##
233
233
  #
234
- # Find a descendant file field on the page and attach a file given its path. The file field can
235
- # be found via its name, id or label text. In the case of the file field being hidden for
234
+ # Find a descendant file field on the page and attach a file given its path. There are two ways to use
235
+ # `attach_file`, in the first method the file field can be found via its name, id or label text.
236
+ # In the case of the file field being hidden for
236
237
  # styling reasons the `make_visible` option can be used to temporarily change the CSS of
237
238
  # the file field, attach the file, and then revert the CSS back to original. If no locator is
238
239
  # passed this will match self or a descendant.
240
+ # The second method, which is currently in beta and may be changed/removed, involves passing a block
241
+ # which performs whatever actions would trigger the file chooser to appear.
239
242
  #
240
243
  # # will attach file to a descendant file input element that has a name, id, or label_text matching 'My File'
241
244
  # page.attach_file('My File', '/path/to/file.png')
@@ -243,6 +246,11 @@ module Capybara
243
246
  # # will attach file to el if it's a file input element
244
247
  # el.attach_file('/path/to/file.png')
245
248
  #
249
+ # # will attach file to whatever file input is triggered by the block
250
+ # page.attach_file('/path/to/file.png') do
251
+ # page.find('#upload_button').click
252
+ # end
253
+ #
246
254
  # @overload attach_file([locator], paths, **options)
247
255
  # @macro waiting_behavior
248
256
  #
@@ -256,19 +264,33 @@ module Capybara
256
264
  # @option options [String] name Match fields that match the name attribute
257
265
  # @option options [String, Array<String>, Regexp] class Match fields that match the class(es) provided
258
266
  # @option options [true, Hash] make_visible A Hash of CSS styles to change before attempting to attach the file, if `true` { opacity: 1, display: 'block', visibility: 'visible' } is used (may not be supported by all drivers)
259
- #
260
- # @return [Capybara::Node::Element] The file field element
267
+ # @overload attach_file(paths, &blk)
268
+ # @param [String, Array<String>] paths The path(s) of the file(s) that will be attached
269
+ # @yield Block whose actions will trigger the system file chooser to be shown
270
+ # @return [Capybara::Node::Element] The file field element
261
271
  def attach_file(locator = nil, paths, make_visible: nil, **options) # rubocop:disable Style/OptionalArguments
272
+ raise ArgumentError, '``#attach_file` does not support passing both a locator and a block' if locator && block_given?
273
+
262
274
  Array(paths).each do |path|
263
275
  raise Capybara::FileNotFound, "cannot attach file, #{path} does not exist" unless File.exist?(path.to_s)
264
276
  end
265
277
  options[:allow_self] = true if locator.nil?
278
+
279
+ if block_given?
280
+ begin
281
+ execute_script CAPTURE_FILE_ELEMENT_SCRIPT
282
+ yield
283
+ file_field = evaluate_script 'window._capybara_clicked_file_input'
284
+ rescue ::Capybara::NotSupportedByDriverError
285
+ warn 'Block mode of `#attach_file` is not supported by the current driver - ignoring.'
286
+ end
287
+ end
266
288
  # Allow user to update the CSS style of the file input since they are so often hidden on a page
267
289
  if make_visible
268
- ff = find(:file_field, locator, options.merge(visible: :all))
290
+ ff = file_field || find(:file_field, locator, options.merge(visible: :all))
269
291
  while_visible(ff, make_visible) { |el| el.set(paths) }
270
292
  else
271
- find(:file_field, locator, options).set(paths)
293
+ (file_field || find(:file_field, locator, options)).set(paths)
272
294
  end
273
295
  end
274
296
 
@@ -369,6 +391,15 @@ module Capybara
369
391
  filter(function(el){ return !el.disabled }).
370
392
  map(function(el){ return { "value": el.value, "label": el.label} })
371
393
  JS
394
+
395
+ CAPTURE_FILE_ELEMENT_SCRIPT = <<~'JS'
396
+ document.addEventListener('click', function(e){
397
+ if (e.target.matches("input[type='file']")) {
398
+ window._capybara_clicked_file_input = e.target;
399
+ e.preventDefault();
400
+ }
401
+ })
402
+ JS
372
403
  end
373
404
  end
374
405
  end
@@ -514,8 +514,16 @@ module Capybara
514
514
  #
515
515
  # page.has_table?('People')
516
516
  #
517
- # @param [String] locator The id or caption of a table
518
- # @return [Boolean] Whether it exist
517
+ # @param [String] locator The id or caption of a table
518
+ # @option options [Array<Array<String>>] :rows
519
+ # Text which should be contained in the tables `<td>` elements organized by row (`<td>` visibility is not considered)
520
+ # @option options [Array<Array<String>>, Array<Hash<String,String>>] :with_rows
521
+ # Partial set of text which should be contained in the tables `<td>` elements organized by row (`<td>` visibility is not considered)
522
+ # @option options [Array<Array<String>>] :cols
523
+ # Text which should be contained in the tables `<td>` elements organized by column (`<td>` visibility is not considered)
524
+ # @option options [Array<Array<String>>, Array<Hash<String,String>>] :with_cols
525
+ # Partial set of text which should be contained in the tables `<td>` elements organized by column (`<td>` visibility is not considered)
526
+ # @return [Boolean] Whether it exists
519
527
  #
520
528
  def has_table?(locator = nil, **options, &optional_filter_block)
521
529
  has_selector?(:table, locator, options, &optional_filter_block)
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'capybara/selector/xpath_extensions'
3
4
  require 'capybara/selector/selector'
5
+
4
6
  Capybara::Selector::FilterSet.add(:_field) do
5
7
  node_filter(:checked, :boolean) { |node, value| !(value ^ node.checked?) }
6
8
  node_filter(:unchecked, :boolean) { |node, value| (value ^ node.checked?) }
@@ -101,7 +103,7 @@ Capybara.add_selector(:link, locator_type: [String, Symbol]) do
101
103
  XPath.attr(:title).is(locator),
102
104
  XPath.descendant(:img)[XPath.attr(:alt).is(locator)]]
103
105
  matchers << XPath.attr(:'aria-label').is(locator) if enable_aria_label
104
- matchers << XPath.attr(test_id) == locator if test_id
106
+ matchers << XPath.attr(test_id).equals(locator) if test_id
105
107
  xpath = xpath[matchers.reduce(:|)]
106
108
  end
107
109
 
@@ -454,11 +456,125 @@ Capybara.add_selector(:table, locator_type: [String, Symbol]) do
454
456
  xpath
455
457
  end
456
458
 
459
+ expression_filter(:with_cols, valid_values: [Array]) do |xpath, cols|
460
+ col_conditions = cols.map do |col|
461
+ if col.is_a? Hash
462
+ col.reduce(nil) do |xp, (header, cell)|
463
+ header_xp = XPath.descendant(:th)[XPath.string.n.is(header)]
464
+ cell_xp = XPath.descendant(:tr)[header_xp].descendant(:td)
465
+ next cell_xp[XPath.string.n.is(cell)] unless xp
466
+
467
+ table_ancestor = XPath.ancestor(:table)[1]
468
+ xp = XPath::Expression.new(:join, table_ancestor, xp)
469
+ cell_xp[XPath.string.n.is(cell) & XPath.position.equals(xp.preceding_sibling(:td).count.plus(1))]
470
+ end
471
+ else
472
+ cells_xp = col.reduce(nil) do |xp, cell|
473
+ cell_conditions = [XPath.string.n.is(cell)]
474
+ if xp
475
+ prev_row_xp = XPath::Expression.new(:join, XPath.ancestor(:tr)[1].preceding_sibling(:tr), xp)
476
+ cell_conditions << XPath.position.equals(prev_row_xp.preceding_sibling(:td).count.plus(1))
477
+ end
478
+ XPath.descendant(:td)[cell_conditions.reduce :&]
479
+ end
480
+ XPath::Expression.new(:join, XPath.descendant(:tr), cells_xp)
481
+ end
482
+ end.reduce(:&)
483
+ xpath[col_conditions]
484
+ end
485
+
486
+ expression_filter(:cols, valid_values: [Array]) do |xpath, cols|
487
+ raise ArgumentError, ":cols must be an Array of Arrays" unless cols.all? { |col| col.is_a? Array }
488
+
489
+ rows = cols.transpose
490
+ xpath = xpath[XPath.descendant(:tbody).descendant(:tr).count.equals(rows.size) | (XPath.descendant(:tr).count.equals(rows.size) & ~XPath.descendant(:tbody))]
491
+
492
+ col_conditions = rows.map do |row|
493
+ row_conditions = row.map do |cell|
494
+ XPath.self(:td)[XPath.string.n.is(cell)]
495
+ end
496
+ row_conditions = row_conditions.reverse.reduce do |cond, cell|
497
+ cell[XPath.following_sibling[cond]]
498
+ end
499
+ row_xpath = XPath.descendant(:tr)[XPath.descendant(:td)[row_conditions]]
500
+ row_xpath[XPath.descendant(:td).count.equals(row.size)]
501
+ end.reduce(:&)
502
+
503
+ xpath[col_conditions]
504
+ end
505
+
506
+ expression_filter(:with_rows, valid_values: [Array]) do |xpath, rows|
507
+ rows_conditions = rows.map do |row|
508
+ if row.is_a? Hash
509
+ row_conditions = row.map do |header, cell|
510
+ header_xp = XPath.ancestor(:table)[1].descendant(:tr)[1].descendant(:th)[XPath.string.n.is(header)]
511
+ XPath.descendant(:td)[
512
+ XPath.string.n.is(cell) & XPath.position.equals(header_xp.preceding_sibling.count.plus(1))
513
+ ]
514
+ end.reduce(:&)
515
+ XPath.descendant(:tr)[row_conditions]
516
+ else
517
+ row_conditions = row.map do |cell|
518
+ XPath.self(:td)[XPath.string.n.is(cell)]
519
+ end
520
+ row_conditions = row_conditions.reverse.reduce do |cond, cell|
521
+ cell[XPath.following_sibling[cond]]
522
+ end
523
+ XPath.descendant(:tr)[XPath.descendant(:td)[row_conditions]]
524
+ end
525
+ end.reduce(:&)
526
+ xpath[rows_conditions]
527
+ end
528
+
529
+ expression_filter(:rows, valid_values: [Array]) do |xpath, rows|
530
+ xpath = xpath[XPath.descendant(:tbody).descendant(:tr).count.equals(rows.size) | (XPath.descendant(:tr).count.equals(rows.size) & ~XPath.descendant(:tbody))]
531
+ rows_conditions = rows.map do |row|
532
+ row_xpath = if row.is_a? Hash
533
+ row_conditions = row.map do |header, cell|
534
+ header_xp = XPath.ancestor(:table)[1].descendant(:tr)[1].descendant(:th)[XPath.string.n.is(header)]
535
+ XPath.descendant(:td)[
536
+ XPath.string.n.is(cell) & XPath.position.equals(header_xp.preceding_sibling.count.plus(1))
537
+ ]
538
+ end.reduce(:&)
539
+ XPath.descendant(:tr)[row_conditions]
540
+ else
541
+ row_conditions = row.map do |cell|
542
+ XPath.self(:td)[XPath.string.n.is(cell)]
543
+ end
544
+ row_conditions = row_conditions.reverse.reduce do |cond, cell|
545
+ cell[XPath.following_sibling[cond]]
546
+ end
547
+ XPath.descendant(:tr)[XPath.descendant(:td)[row_conditions]]
548
+ end
549
+ row_xpath[XPath.descendant(:td).count.equals(row.size)]
550
+ end.reduce(:&)
551
+ xpath[rows_conditions]
552
+ end
553
+
457
554
  describe_expression_filters do |caption: nil, **|
458
555
  " with caption \"#{caption}\"" if caption
459
556
  end
460
557
  end
461
558
 
559
+ Capybara.add_selector(:table_row, locator_type: [Array, Hash]) do
560
+ xpath do |locator|
561
+ xpath = XPath.descendant(:tr)
562
+ if locator.is_a? Hash
563
+ locator.reduce(xpath) do |xp, (header, cell)|
564
+ header_xp = XPath.ancestor(:table)[1].descendant(:tr)[1].descendant(:th)[XPath.string.n.is(header)]
565
+ cell_xp = XPath.descendant(:td)[
566
+ XPath.string.n.is(cell) & XPath.position.equals(header_xp.preceding_sibling.count.plus(1))
567
+ ]
568
+ xp[cell_xp]
569
+ end
570
+ else
571
+ initial_td = XPath.descendant(:td)[XPath.string.n.is(locator.shift)]
572
+ tds = locator.reverse.map { |cell| XPath.following_sibling(:td)[XPath.string.n.is(cell)] }.reduce { |xp, cell| xp[cell] }
573
+ xpath[initial_td[tds]]
574
+ end
575
+ end
576
+ end
577
+
462
578
  Capybara.add_selector(:frame, locator_type: [String, Symbol]) do
463
579
  xpath do |locator, name: nil, **|
464
580
  xpath = XPath.descendant(:iframe).union(XPath.descendant(:frame))
@@ -158,6 +158,13 @@ module Capybara
158
158
  # * :caption (String) — Match text of associated caption
159
159
  # * :class ((String, Array<String>, Regexp, XPath::Expression) — Matches the class(es) provided
160
160
  # * :style (String, Regexp, Hash)
161
+ # * :with_rows (Array<Array<String>>, Array<Hash<String, String>>) - Partial match <td> data - visibility of <td> elements is not considered
162
+ # * :rows (Array<Array<String>>) — Match all <td>s - visibility of <td> elements is not considered
163
+ # * :with_cols (Array<Array<String>>, Array<Hash<String, String>>) - Partial match <td> data - visibility of <td> elements is not considered
164
+ # * :cols (Array<Array<String>>) — Match all <td>s - visibility of <td> elements is not considered
165
+ #
166
+ # * **:table_row** - Find table row
167
+ # * Locator: Array<String>, Hash<String,String> table row <td> contents - visibility of <td> elements is not considered
161
168
  #
162
169
  # * **:frame** - Find frame/iframe elements
163
170
  # * Locator: Match id or name
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XPath
4
+ class Renderer
5
+ def join(*expressions)
6
+ expressions.join('/')
7
+ end
8
+ end
9
+ end
@@ -267,7 +267,11 @@ private
267
267
  if @browser.respond_to? :session_storage
268
268
  @browser.session_storage.clear
269
269
  else
270
- warn 'sessionStorage clear requested but is not supported by this driver' unless options[:clear_session_storage].nil?
270
+ begin
271
+ @browser&.execute_script('window.sessionStorage.clear()')
272
+ rescue # rubocop:disable Style/RescueStandardError
273
+ warn 'sessionStorage clear requested but is not supported by this driver' unless options[:clear_session_storage].nil?
274
+ end
271
275
  end
272
276
  end
273
277
 
@@ -275,7 +279,11 @@ private
275
279
  if @browser.respond_to? :local_storage
276
280
  @browser.local_storage.clear
277
281
  else
278
- warn 'localStorage clear requested but is not supported by this driver' unless options[:clear_local_storage].nil?
282
+ begin
283
+ @browser&.execute_script('window.localStorage.clear()')
284
+ rescue # rubocop:disable Style/RescueStandardError
285
+ warn 'localStorage clear requested but is not supported by this driver' unless options[:clear_local_storage].nil?
286
+ end
279
287
  end
280
288
  end
281
289
 
@@ -359,6 +367,8 @@ private
359
367
  extend FirefoxDriver if sel_driver.capabilities.is_a?(::Selenium::WebDriver::Remote::W3C::Capabilities)
360
368
  when :ie, :internet_explorer
361
369
  extend InternetExplorerDriver
370
+ when :safari, :Safari_Technology_Preview
371
+ extend SafariDriver
362
372
  end
363
373
  end
364
374
 
@@ -408,3 +418,4 @@ end
408
418
  require 'capybara/selenium/driver_specializations/chrome_driver'
409
419
  require 'capybara/selenium/driver_specializations/firefox_driver'
410
420
  require 'capybara/selenium/driver_specializations/internet_explorer_driver'
421
+ require 'capybara/selenium/driver_specializations/safari_driver'
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'capybara/selenium/nodes/safari_node'
4
+
5
+ module Capybara::Selenium::Driver::SafariDriver
6
+ private
7
+
8
+ def build_node(native_node, initial_cache = {})
9
+ ::Capybara::Selenium::SafariNode.new(self, native_node, initial_cache)
10
+ end
11
+
12
+ def bridge
13
+ browser.send(:bridge)
14
+ end
15
+ end
@@ -45,7 +45,8 @@ module Capybara
45
45
  unless functions.empty?
46
46
  hints_js << <<~EACH_JS
47
47
  return arguments[0].map(function(el){
48
- return [#{functions.join(',')}].map(function(fn){ return fn.call(null, el) }); });
48
+ return [#{functions.join(',')}].map(function(fn){ return fn.call(null, el) });
49
+ });
49
50
  EACH_JS
50
51
 
51
52
  hints = es_context.execute_script hints_js, els
@@ -398,7 +398,7 @@ private
398
398
  end
399
399
 
400
400
  def to_date_str
401
- value.to_date.strftime('%Y-%m-%d')
401
+ value.to_date.iso8601
402
402
  end
403
403
 
404
404
  def timeable?
@@ -119,7 +119,7 @@ private
119
119
  x.parent(:fieldset)[
120
120
  x.attr(:disabled)
121
121
  ] + x.ancestor[
122
- ~x.self(:legned) |
122
+ ~x.self(:legend) |
123
123
  x.preceding_sibling(:legend)
124
124
  ][
125
125
  x.parent(:fieldset)[
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ # require 'capybara/selenium/extensions/html5_drag'
4
+
5
+ class Capybara::Selenium::SafariNode < Capybara::Selenium::Node
6
+ # include Html5Drag
7
+
8
+ def click(keys = [], **options)
9
+ # driver.execute_script('arguments[0].scrollIntoViewIfNeeded({block: "center"})', self)
10
+ super
11
+ rescue ::Selenium::WebDriver::Error::ElementNotInteractableError
12
+ if tag_name == 'tr'
13
+ warn 'You are attempting to click a table row which has issues in safaridriver - '\
14
+ 'Your test should probably be clicking on a table cell like a user would. '\
15
+ 'Clicking the first cell in the row instead.'
16
+ return find_css('th:first-child,td:first-child')[0].click(keys, options)
17
+ end
18
+ raise
19
+ end
20
+
21
+ def select_option
22
+ driver.execute_script("arguments[0].closest('select').scrollIntoView()", self)
23
+ super
24
+ end
25
+
26
+ def unselect_option
27
+ driver.execute_script("arguments[0].closest('select').scrollIntoView()", self)
28
+ super
29
+ end
30
+
31
+ def visible_text
32
+ return '' unless visible?
33
+
34
+ vis_text = driver.execute_script('return arguments[0].innerText', self)
35
+ vis_text.gsub(/\ +/, ' ')
36
+ .gsub(/[\ \n]*\n[\ \n]*/, "\n")
37
+ .gsub(/\A[[:space:]&&[^\u00a0]]+/, '')
38
+ .gsub(/[[:space:]&&[^\u00a0]]+\z/, '')
39
+ .tr("\u00a0", ' ')
40
+ end
41
+
42
+ def disabled?
43
+ return true if super || (self[:disabled] == 'true')
44
+
45
+ # workaround for safaridriver reporting elements as enabled when they are nested in disabling elements
46
+ if %w[option optgroup].include? tag_name
47
+ find_xpath('parent::*[self::optgroup or self::select]')[0].disabled?
48
+ else
49
+ !find_xpath(DISABLED_BY_FIELDSET_XPATH).empty?
50
+ end
51
+ end
52
+
53
+ def set_file(value) # rubocop:disable Naming/AccessorMethodName
54
+ # By default files are appended so we have to clear here if its multiple and already set
55
+ native.clear if multiple? && driver.evaluate_script('arguments[0].files', self).any?
56
+ super
57
+ end
58
+
59
+ def send_keys(*args)
60
+ return super(*args.map { |arg| arg == :space ? ' ' : arg }) if args.none? { |arg| arg.is_a? Array }
61
+
62
+ native.click
63
+ _send_keys(args).perform
64
+ end
65
+
66
+ def set_text(value, clear: nil, **_unused)
67
+ value = value.to_s
68
+ if clear == :backspace
69
+ # Clear field by sending the correct number of backspace keys.
70
+ backspaces = [:backspace] * self.value.to_s.length
71
+ send_keys(*([[:control, 'e']] + backspaces + [value]))
72
+ else
73
+ super.tap do
74
+ # React doesn't see the safaridriver element clear
75
+ send_keys(:space, :backspace) if value.to_s.empty? && clear.nil?
76
+ end
77
+ end
78
+ end
79
+
80
+ private
81
+
82
+ def bridge
83
+ driver.browser.send(:bridge)
84
+ end
85
+
86
+ DISABLED_BY_FIELDSET_XPATH = XPath.generate do |x|
87
+ x.parent(:fieldset)[
88
+ x.attr(:disabled)
89
+ ] + x.ancestor[
90
+ ~x.self(:legend) |
91
+ x.preceding_sibling(:legend)
92
+ ][
93
+ x.parent(:fieldset)[
94
+ x.attr(:disabled)
95
+ ]
96
+ ]
97
+ end.to_s.freeze
98
+
99
+ def _send_keys(keys, actions = browser_action, down_keys = ModifierKeysStack.new)
100
+ case keys
101
+ when :control, :left_control, :right_control,
102
+ :alt, :left_alt, :right_alt,
103
+ :shift, :left_shift, :right_shift,
104
+ :meta, :left_meta, :right_meta,
105
+ :command
106
+ down_keys.press(keys)
107
+ actions.key_down(keys)
108
+ when String
109
+ keys = keys.upcase if down_keys&.include?(:shift)
110
+ actions.send_keys(keys)
111
+ when Symbol
112
+ actions.send_keys(keys)
113
+ when Array
114
+ down_keys.push
115
+ keys.each { |sub_keys| _send_keys(sub_keys, actions, down_keys) }
116
+ down_keys.pop.reverse_each { |key| actions.key_up(key) }
117
+ else
118
+ raise ArgumentError, 'Unknown keys type'
119
+ end
120
+ actions
121
+ end
122
+
123
+ class ModifierKeysStack
124
+ def initialize
125
+ @stack = []
126
+ end
127
+
128
+ def include?(key)
129
+ @stack.flatten.include?(key)
130
+ end
131
+
132
+ def press(key)
133
+ @stack.last.push(key)
134
+ end
135
+
136
+ def push
137
+ @stack.push []
138
+ end
139
+
140
+ def pop
141
+ @stack.pop
142
+ end
143
+ end
144
+ private_constant :ModifierKeysStack
145
+ end