capybara 3.15.0 → 3.17.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/History.md +39 -0
  3. data/README.md +3 -3
  4. data/lib/capybara/config.rb +1 -2
  5. data/lib/capybara/helpers.rb +1 -1
  6. data/lib/capybara/node/actions.rb +6 -6
  7. data/lib/capybara/node/base.rb +5 -5
  8. data/lib/capybara/node/element.rb +5 -5
  9. data/lib/capybara/node/finders.rb +7 -5
  10. data/lib/capybara/node/simple.rb +3 -2
  11. data/lib/capybara/queries/base_query.rb +1 -1
  12. data/lib/capybara/queries/selector_query.rb +7 -7
  13. data/lib/capybara/queries/style_query.rb +1 -1
  14. data/lib/capybara/rack_test/form.rb +1 -1
  15. data/lib/capybara/registrations/drivers.rb +36 -0
  16. data/lib/capybara/registrations/servers.rb +38 -0
  17. data/lib/capybara/result.rb +2 -2
  18. data/lib/capybara/rspec/matcher_proxies.rb +2 -2
  19. data/lib/capybara/rspec/matchers/base.rb +2 -2
  20. data/lib/capybara/rspec/matchers.rb +1 -1
  21. data/lib/capybara/selector/css.rb +3 -3
  22. data/lib/capybara/selector/filters/base.rb +1 -1
  23. data/lib/capybara/selector/selector.rb +1 -0
  24. data/lib/capybara/selector/xpath_extensions.rb +8 -0
  25. data/lib/capybara/selector.rb +117 -102
  26. data/lib/capybara/selenium/driver.rb +97 -48
  27. data/lib/capybara/selenium/driver_specializations/chrome_driver.rb +16 -4
  28. data/lib/capybara/selenium/driver_specializations/firefox_driver.rb +23 -0
  29. data/lib/capybara/selenium/driver_specializations/internet_explorer_driver.rb +5 -0
  30. data/lib/capybara/selenium/driver_specializations/safari_driver.rb +13 -0
  31. data/lib/capybara/selenium/extensions/find.rb +48 -37
  32. data/lib/capybara/selenium/extensions/html5_drag.rb +3 -1
  33. data/lib/capybara/selenium/logger_suppressor.rb +29 -0
  34. data/lib/capybara/selenium/node.rb +22 -8
  35. data/lib/capybara/selenium/nodes/chrome_node.rb +8 -4
  36. data/lib/capybara/selenium/patches/persistent_client.rb +20 -0
  37. data/lib/capybara/server/animation_disabler.rb +1 -1
  38. data/lib/capybara/server/checker.rb +1 -1
  39. data/lib/capybara/server/middleware.rb +3 -3
  40. data/lib/capybara/session/config.rb +2 -8
  41. data/lib/capybara/session.rb +9 -5
  42. data/lib/capybara/spec/session/attach_file_spec.rb +1 -1
  43. data/lib/capybara/spec/session/check_spec.rb +4 -4
  44. data/lib/capybara/spec/session/choose_spec.rb +2 -2
  45. data/lib/capybara/spec/session/click_button_spec.rb +28 -1
  46. data/lib/capybara/spec/session/fill_in_spec.rb +2 -2
  47. data/lib/capybara/spec/session/find_spec.rb +1 -1
  48. data/lib/capybara/spec/session/frame/switch_to_frame_spec.rb +14 -1
  49. data/lib/capybara/spec/session/frame/within_frame_spec.rb +12 -1
  50. data/lib/capybara/spec/session/has_css_spec.rb +9 -2
  51. data/lib/capybara/spec/session/has_table_spec.rb +4 -4
  52. data/lib/capybara/spec/session/node_spec.rb +18 -6
  53. data/lib/capybara/spec/session/uncheck_spec.rb +2 -2
  54. data/lib/capybara/spec/session/unselect_spec.rb +1 -1
  55. data/lib/capybara/spec/spec_helper.rb +1 -1
  56. data/lib/capybara/spec/views/frame_child.erb +2 -1
  57. data/lib/capybara/spec/views/react.erb +45 -0
  58. data/lib/capybara/version.rb +1 -1
  59. data/lib/capybara/window.rb +1 -1
  60. data/lib/capybara.rb +2 -70
  61. data/spec/minitest_spec_spec.rb +1 -1
  62. data/spec/result_spec.rb +10 -6
  63. data/spec/rspec/shared_spec_matchers.rb +8 -4
  64. data/spec/sauce_spec_chrome.rb +42 -0
  65. data/spec/selector_spec.rb +4 -0
  66. data/spec/selenium_spec_safari.rb +2 -3
  67. data/spec/session_spec.rb +7 -0
  68. data/spec/shared_selenium_session.rb +14 -12
  69. data/spec/spec_helper.rb +2 -1
  70. metadata +37 -17
@@ -7,20 +7,28 @@ Capybara::Selector::FilterSet.add(:_field) do
7
7
  node_filter(:checked, :boolean) { |node, value| !(value ^ node.checked?) }
8
8
  node_filter(:unchecked, :boolean) { |node, value| (value ^ node.checked?) }
9
9
  node_filter(:disabled, :boolean, default: false, skip_if: :all) { |node, value| !(value ^ node.disabled?) }
10
- node_filter(:multiple, :boolean) { |node, value| !(value ^ node.multiple?) }
11
10
 
12
11
  expression_filter(:name) { |xpath, val| xpath[XPath.attr(:name) == val] }
13
12
  expression_filter(:placeholder) { |xpath, val| xpath[XPath.attr(:placeholder) == val] }
13
+ expression_filter(:disabled) { |xpath, val| val ? xpath : xpath[~XPath.attr(:disabled)] }
14
+ expression_filter(:multiple) { |xpath, val| xpath[val ? XPath.attr(:multiple) : ~XPath.attr(:multiple)] }
14
15
 
15
- describe(:node_filters) do |checked: nil, unchecked: nil, disabled: nil, multiple: nil, **|
16
+ describe(:expression_filters) do |name: nil, placeholder: nil, disabled: nil, multiple: nil, **|
17
+ desc = +''
18
+ desc << ' that is not disabled' if disabled == false
19
+ desc << " with name #{name}" if name
20
+ desc << " with placeholder #{placeholder}" if placeholder
21
+ desc << ' with the multiple attribute' if multiple == true
22
+ desc << ' without the multiple attribute' if multiple == false
23
+ desc
24
+ end
25
+
26
+ describe(:node_filters) do |checked: nil, unchecked: nil, disabled: nil, **|
16
27
  desc, states = +'', []
17
28
  states << 'checked' if checked || (unchecked == false)
18
29
  states << 'not checked' if unchecked || (checked == false)
19
30
  states << 'disabled' if disabled == true
20
- states << 'not disabled' if disabled == false
21
31
  desc << " that is #{states.join(' and ')}" unless states.empty?
22
- desc << ' with the multiple attribute' if multiple == true
23
- desc << ' without the multiple attribute' if multiple == false
24
32
  desc
25
33
  end
26
34
  end
@@ -37,7 +45,7 @@ end
37
45
 
38
46
  Capybara.add_selector(:id, locator_type: [String, Symbol, Regexp]) do
39
47
  xpath { |id| builder(XPath.descendant).add_attribute_conditions(id: id) }
40
- locator_filter { |node, id| id.is_a?(Regexp) ? node[:id] =~ id : true }
48
+ locator_filter { |node, id| id.is_a?(Regexp) ? id.match?(node[:id]) : true }
41
49
  end
42
50
 
43
51
  Capybara.add_selector(:field, locator_type: [String, Symbol]) do
@@ -63,16 +71,13 @@ Capybara.add_selector(:field, locator_type: [String, Symbol]) do
63
71
  node_filter(:readonly, :boolean) { |node, value| !(value ^ node.readonly?) }
64
72
  node_filter(:with) do |node, with|
65
73
  val = node.value
66
- (with.is_a?(Regexp) ? val =~ with : val == with.to_s).tap do |res|
74
+ (with.is_a?(Regexp) ? with.match?(val) : val == with.to_s).tap do |res|
67
75
  add_error("Expected value to be #{with.inspect} but was #{val.inspect}") unless res
68
76
  end
69
77
  end
70
78
 
71
- describe_expression_filters do |type: nil, **options|
72
- desc = +''
73
- (expression_filters.keys & options.keys).each { |ef| desc << " with #{ef} #{options[ef]}" }
74
- desc << " of type #{type.inspect}" if type
75
- desc
79
+ describe_expression_filters do |type: nil, **|
80
+ " of type #{type.inspect}" if type
76
81
  end
77
82
 
78
83
  describe_node_filters do |**options|
@@ -90,6 +95,7 @@ Capybara.add_selector(:fieldset, locator_type: [String, Symbol]) do
90
95
  end
91
96
 
92
97
  node_filter(:disabled, :boolean) { |node, value| !(value ^ node.disabled?) }
98
+ expression_filter(:disabled) { |xpath, val| val ? xpath : xpath[~XPath.attr(:disabled)] }
93
99
  end
94
100
 
95
101
  Capybara.add_selector(:link, locator_type: [String, Symbol]) do
@@ -123,26 +129,28 @@ Capybara.add_selector(:link, locator_type: [String, Symbol]) do
123
129
  builder(expr).add_attribute_conditions(download: download)
124
130
  end
125
131
 
126
- describe_expression_filters do |**options|
132
+ describe_expression_filters do |download: nil, **options|
127
133
  desc = +''
128
134
  if (href = options[:href])
129
135
  desc << " with href #{'matching ' if href.is_a? Regexp}#{href.inspect}"
130
136
  elsif options.key?(:href) # is nil/false specified?
131
137
  desc << ' with no href attribute'
132
138
  end
139
+ desc << " with download attribute#{" #{download}" if download.is_a? String}" if download
140
+ desc << ' without download attribute' if download == false
133
141
  desc
134
142
  end
135
143
  end
136
144
 
137
145
  Capybara.add_selector(:button, locator_type: [String, Symbol]) do
138
- xpath(:value, :title, :type) do |locator, **options|
146
+ xpath(:value, :title, :type, :name) do |locator, **options|
139
147
  input_btn_xpath = XPath.descendant(:input)[XPath.attr(:type).one_of('submit', 'reset', 'image', 'button')]
140
148
  btn_xpath = XPath.descendant(:button)
141
149
  image_btn_xpath = XPath.descendant(:input)[XPath.attr(:type) == 'image']
142
150
 
143
151
  unless locator.nil?
144
152
  locator = locator.to_s
145
- locator_matchers = XPath.attr(:id).equals(locator) | XPath.attr(:value).is(locator) | XPath.attr(:title).is(locator)
153
+ locator_matchers = XPath.attr(:id).equals(locator) | XPath.attr(:name).equals(locator) | XPath.attr(:value).is(locator) | XPath.attr(:title).is(locator)
146
154
  locator_matchers |= XPath.attr(:'aria-label').is(locator) if enable_aria_label
147
155
  locator_matchers |= XPath.attr(test_id) == locator if test_id
148
156
 
@@ -155,14 +163,20 @@ Capybara.add_selector(:button, locator_type: [String, Symbol]) do
155
163
  image_btn_xpath = image_btn_xpath[alt_matches]
156
164
  end
157
165
 
158
- %i[value title type].inject(input_btn_xpath.union(btn_xpath).union(image_btn_xpath)) do |memo, ef|
166
+ %i[value title type name].inject(input_btn_xpath.union(btn_xpath).union(image_btn_xpath)) do |memo, ef|
159
167
  memo[find_by_attr(ef, options[ef])]
160
168
  end
161
169
  end
162
170
 
163
171
  node_filter(:disabled, :boolean, default: false, skip_if: :all) { |node, value| !(value ^ node.disabled?) }
172
+ expression_filter(:disabled) { |xpath, val| val ? xpath : xpath[~XPath.attr(:disabled)] }
173
+
174
+ describe_expression_filters do |disabled: nil, **options|
175
+ desc = +''
176
+ desc << ' that is not disabled' if disabled == false
177
+ desc << describe_all_expression_filters(options)
178
+ end
164
179
 
165
- describe_expression_filters
166
180
  describe_node_filters do |disabled: nil, **|
167
181
  ' that is disabled' if disabled == true
168
182
  end
@@ -176,7 +190,7 @@ Capybara.add_selector(:link_or_button, locator_type: [String, Symbol]) do
176
190
  end.reduce(:union)
177
191
  end
178
192
 
179
- node_filter(:disabled, :boolean, default: false, skip_if: :all) { |node, value| node.tag_name == 'a' || !(value ^ node.disabled?) }
193
+ node_filter(:disabled, :boolean, default: false, skip_if: :all) { |node, value| !(value ^ node.disabled?) }
180
194
 
181
195
  describe_node_filters do |disabled: nil, **|
182
196
  ' that is disabled' if disabled == true
@@ -205,12 +219,11 @@ Capybara.add_selector(:fillable_field, locator_type: [String, Symbol]) do
205
219
 
206
220
  node_filter(:with) do |node, with|
207
221
  val = node.value
208
- (with.is_a?(Regexp) ? val =~ with : val == with.to_s).tap do |res|
222
+ (with.is_a?(Regexp) ? with.match?(val) : val == with.to_s).tap do |res|
209
223
  add_error("Expected value to be #{with.inspect} but was #{val.inspect}") unless res
210
224
  end
211
225
  end
212
226
 
213
- describe_expression_filters
214
227
  describe_node_filters do |**options|
215
228
  " with value #{options[:with].to_s.inspect}" if options.key?(:with)
216
229
  end
@@ -234,7 +247,6 @@ Capybara.add_selector(:radio_button, locator_type: [String, Symbol]) do
234
247
  end
235
248
  end
236
249
 
237
- describe_expression_filters
238
250
  describe_node_filters do |option: nil, **|
239
251
  " with value #{option.inspect}" if option
240
252
  end
@@ -257,7 +269,6 @@ Capybara.add_selector(:checkbox, locator_type: [String, Symbol]) do
257
269
  end
258
270
  end
259
271
 
260
- describe_expression_filters
261
272
  describe_node_filters do |option: nil, **|
262
273
  " with value #{option.inspect}" if option
263
274
  end
@@ -304,18 +315,18 @@ Capybara.add_selector(:select, locator_type: [String, Symbol]) do
304
315
  end
305
316
  end
306
317
 
307
- describe_expression_filters do |with_options: nil, **opts|
318
+ describe_expression_filters do |with_options: nil, **|
308
319
  desc = +''
309
320
  desc << " with at least options #{with_options.inspect}" if with_options
310
- desc << describe_all_expression_filters(opts)
311
321
  desc
312
322
  end
313
323
 
314
- describe_node_filters do |options: nil, selected: nil, with_selected: nil, **|
324
+ describe_node_filters do |options: nil, selected: nil, with_selected: nil, disabled: nil, **|
315
325
  desc = +''
316
326
  desc << " with options #{options.inspect}" if options
317
327
  desc << " with #{selected.inspect} selected" if selected
318
328
  desc << " with at least #{with_selected.inspect} selected" if with_selected
329
+ desc << ' which is disabled' if disabled
319
330
  desc
320
331
  end
321
332
  end
@@ -343,10 +354,9 @@ Capybara.add_selector(:datalist_input, locator_type: [String, Symbol]) do
343
354
  end
344
355
  end
345
356
 
346
- describe_expression_filters do |with_options: nil, **opts|
357
+ describe_expression_filters do |with_options: nil, **|
347
358
  desc = +''
348
359
  desc << " with at least options #{with_options.inspect}" if with_options
349
- desc << describe_all_expression_filters(opts)
350
360
  desc
351
361
  end
352
362
 
@@ -363,11 +373,19 @@ Capybara.add_selector(:option, locator_type: [String, Symbol]) do
363
373
  end
364
374
 
365
375
  node_filter(:disabled, :boolean) { |node, value| !(value ^ node.disabled?) }
376
+ expression_filter(:disabled) { |xpath, val| val ? xpath : xpath[~XPath.attr(:disabled)] }
377
+
366
378
  node_filter(:selected, :boolean) { |node, value| !(value ^ node.selected?) }
367
379
 
380
+ describe_expression_filters do |disabled: nil, **options|
381
+ desc = +''
382
+ desc << ' that is not disabled' if disabled == false
383
+ (expression_filters.keys & options.keys).inject(desc) { |memo, ef| memo << " with #{ef} #{options[ef]}" }
384
+ end
385
+
368
386
  describe_node_filters do |**options|
369
387
  desc = +''
370
- desc << " that is#{' not' unless options[:disabled]} disabled" if options.key?(:disabled)
388
+ desc << ' that is disabled' if options[:disabled]
371
389
  desc << " that is#{' not' unless options[:selected]} selected" if options.key?(:selected)
372
390
  desc
373
391
  end
@@ -384,9 +402,16 @@ Capybara.add_selector(:datalist_option, locator_type: [String, Symbol]) do
384
402
  end
385
403
 
386
404
  node_filter(:disabled, :boolean) { |node, value| !(value ^ node.disabled?) }
405
+ expression_filter(:disabled) { |xpath, val| val ? xpath : xpath[~XPath.attr(:disabled)] }
406
+
407
+ describe_expression_filters do |disabled: nil, **options|
408
+ desc = +''
409
+ desc << ' that is not disabled' if disabled == false
410
+ desc << describe_all_expression_filters(options)
411
+ end
387
412
 
388
413
  describe_node_filters do |**options|
389
- " that is#{' not' unless options[:disabled]} disabled" if options.key?(:disabled)
414
+ ' that is disabled' if options[:disabled]
390
415
  end
391
416
  end
392
417
 
@@ -400,8 +425,6 @@ Capybara.add_selector(:file_field, locator_type: [String, Symbol]) do
400
425
  end
401
426
 
402
427
  filter_set(:_field, %i[disabled multiple name])
403
-
404
- describe_expression_filters
405
428
  end
406
429
 
407
430
  Capybara.add_selector(:label, locator_type: [String, Symbol]) do
@@ -459,100 +482,92 @@ Capybara.add_selector(:table, locator_type: [String, Symbol]) do
459
482
  expression_filter(:with_cols, valid_values: [Array]) do |xpath, cols|
460
483
  col_conditions = cols.map do |col|
461
484
  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))]
485
+ col.reduce(nil) do |xp, (header, cell_str)|
486
+ header = XPath.descendant(:th)[XPath.string.n.is(header)]
487
+ td = XPath.descendant(:tr)[header].descendant(:td)
488
+ cell_condition = XPath.string.n.is(cell_str)
489
+ cell_condition &= prev_col_position?(XPath.ancestor(:table)[1].join(xp)) if xp
490
+ td[cell_condition]
470
491
  end
471
492
  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))
493
+ cells_xp = col.reduce(nil) do |prev_cell, cell_str|
494
+ cell_condition = XPath.string.n.is(cell_str)
495
+
496
+ if prev_cell
497
+ prev_cell = XPath.ancestor(:tr)[1].preceding_sibling(:tr).join(prev_cell)
498
+ cell_condition &= prev_col_position?(prev_cell)
477
499
  end
478
- XPath.descendant(:td)[cell_conditions.reduce :&]
500
+
501
+ XPath.descendant(:td)[cell_condition]
479
502
  end
480
- XPath::Expression.new(:join, XPath.descendant(:tr), cells_xp)
503
+ XPath.descendant(:tr).join(cells_xp)
481
504
  end
482
505
  end.reduce(:&)
483
506
  xpath[col_conditions]
484
507
  end
485
508
 
486
509
  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 }
510
+ raise ArgumentError, ':cols must be an Array of Arrays' unless cols.all? { |col| col.is_a? Array }
488
511
 
489
512
  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]
513
+ col_conditions = rows.map { |row| match_row(row, match_size: true) }.reduce(:&)
514
+ xpath[match_row_count(rows.size)][col_conditions]
504
515
  end
505
516
 
506
517
  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(:&)
518
+ rows_conditions = rows.map { |row| match_row(row) }.reduce(:&)
526
519
  xpath[rows_conditions]
527
520
  end
528
521
 
529
522
  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]
523
+ rows_conditions = rows.map { |row| match_row(row, match_size: true) }.reduce(:&)
524
+ xpath[match_row_count(rows.size)][rows_conditions]
525
+ end
526
+
527
+ describe_expression_filters do |caption: nil, **|
528
+ " with caption \"#{caption}\"" if caption
529
+ end
530
+
531
+ def prev_col_position?(cell)
532
+ XPath.position.equals(cell_position(cell))
533
+ end
534
+
535
+ def cell_position(cell)
536
+ cell.preceding_sibling(:td).count.plus(1)
537
+ end
538
+
539
+ def match_row(row, match_size: false)
540
+ xp = XPath.descendant(:tr)[
541
+ if row.is_a? Hash
542
+ row_match_cells_to_headers(row)
540
543
  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]]
544
+ XPath.descendant(:td)[row_match_ordered_cells(row)]
548
545
  end
549
- row_xpath[XPath.descendant(:td).count.equals(row.size)]
546
+ ]
547
+ xp = xp[XPath.descendant(:td).count.equals(row.size)] if match_size
548
+ xp
549
+ end
550
+
551
+ def match_row_count(size)
552
+ XPath.descendant(:tbody).descendant(:tr).count.equals(size) | (XPath.descendant(:tr).count.equals(size) & ~XPath.descendant(:tbody))
553
+ end
554
+
555
+ def row_match_cells_to_headers(row)
556
+ row.map do |header, cell|
557
+ header_xp = XPath.ancestor(:table)[1].descendant(:tr)[1].descendant(:th)[XPath.string.n.is(header)]
558
+ XPath.descendant(:td)[
559
+ XPath.string.n.is(cell) & XPath.position.equals(header_xp.preceding_sibling.count.plus(1))
560
+ ]
550
561
  end.reduce(:&)
551
- xpath[rows_conditions]
552
562
  end
553
563
 
554
- describe_expression_filters do |caption: nil, **|
555
- " with caption \"#{caption}\"" if caption
564
+ def row_match_ordered_cells(row)
565
+ row_conditions = row.map do |cell|
566
+ XPath.self(:td)[XPath.string.n.is(cell)]
567
+ end
568
+ row_conditions.reverse.reduce do |cond, cell|
569
+ cell[XPath.following_sibling[cond]]
570
+ end
556
571
  end
557
572
  end
558
573
 
@@ -603,7 +618,7 @@ Capybara.add_selector(:element, locator_type: [String, Symbol]) do
603
618
  node_filter(:attributes, matcher: /.+/) do |node, name, val|
604
619
  next true unless val.is_a?(Regexp)
605
620
 
606
- (node[name] =~ val).tap do |res|
621
+ (val.match? node[name]).tap do |res|
607
622
  add_error("Expected #{name} to match #{val.inspect} but it was #{node[name]}") unless res
608
623
  end
609
624
  end
@@ -14,25 +14,40 @@ class Capybara::Selenium::Driver < Capybara::Driver::Base
14
14
  SPECIAL_OPTIONS = %i[browser clear_local_storage clear_session_storage timeout].freeze
15
15
  attr_reader :app, :options
16
16
 
17
- def self.load_selenium
18
- require 'selenium-webdriver'
19
- 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')
20
- rescue LoadError => err
21
- raise err if err.message !~ /selenium-webdriver/
17
+ class << self
18
+ def load_selenium
19
+ require 'selenium-webdriver'
20
+ require 'capybara/selenium/logger_suppressor'
21
+ 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
+ rescue LoadError => e
23
+ raise e unless e.message.match?(/selenium-webdriver/)
24
+
25
+ raise LoadError, "Capybara's selenium driver is unable to load `selenium-webdriver`, please install the gem and add `gem 'selenium-webdriver'` to your Gemfile if you are using bundler."
26
+ end
27
+
28
+ attr_reader :specializations
22
29
 
23
- raise LoadError, "Capybara's selenium driver is unable to load `selenium-webdriver`, please install the gem and add `gem 'selenium-webdriver'` to your Gemfile if you are using bundler."
30
+ def register_specialization(browser_name, specialization)
31
+ @specializations ||= {}
32
+ @specializations[browser_name] = specialization
33
+ end
24
34
  end
25
35
 
26
36
  def browser
27
- @browser ||= begin
28
- if options[:timeout]
29
- options[:http_client] ||= Selenium::WebDriver::Remote::Http::Default.new(read_timeout: options[:timeout])
37
+ unless @browser
38
+ options[:http_client] ||= begin
39
+ require 'capybara/selenium/patches/persistent_client'
40
+ if options[:timeout]
41
+ ::Capybara::Selenium::PersistentClient.new(read_timeout: options[:timeout])
42
+ else
43
+ ::Capybara::Selenium::PersistentClient.new
44
+ end
30
45
  end
31
46
  processed_options = options.reject { |key, _val| SPECIAL_OPTIONS.include?(key) }
32
- Selenium::WebDriver.for(options[:browser], processed_options).tap do |driver|
33
- specialize_driver(driver)
34
- setup_exit_handler
35
- end
47
+ @browser = Selenium::WebDriver.for(options[:browser], processed_options)
48
+
49
+ specialize_driver
50
+ setup_exit_handler
36
51
  end
37
52
  @browser
38
53
  end
@@ -110,7 +125,7 @@ class Capybara::Selenium::Driver < Capybara::Driver::Base
110
125
  navigated = true
111
126
  # Ensure the page is empty and trigger an UnhandledAlertError for any modals that appear during unload
112
127
  wait_for_empty_page(timer)
113
- rescue Selenium::WebDriver::Error::UnhandledAlertError, Selenium::WebDriver::Error::UnexpectedAlertOpenError
128
+ rescue *unhandled_alert_errors
114
129
  # This error is thrown if an unhandled alert is on the page
115
130
  # Firefox appears to automatically dismiss this alert, chrome does not
116
131
  # We'll try to accept it
@@ -177,7 +192,10 @@ class Capybara::Selenium::Driver < Capybara::Driver::Base
177
192
  browser.window_handles
178
193
  end
179
194
 
180
- def open_new_window
195
+ def open_new_window(kind = :tab)
196
+ browser.manage.new_window(kind)
197
+ rescue NoMethodError, Selenium::WebDriver::Error::WebDriverError
198
+ # If not supported by the driver or browser default to using JS
181
199
  browser.execute_script('window.open();')
182
200
  end
183
201
 
@@ -218,19 +236,27 @@ class Capybara::Selenium::Driver < Capybara::Driver::Base
218
236
  end
219
237
 
220
238
  def invalid_element_errors
221
- [
222
- ::Selenium::WebDriver::Error::StaleElementReferenceError,
223
- ::Selenium::WebDriver::Error::UnhandledError,
224
- ::Selenium::WebDriver::Error::ElementNotVisibleError,
225
- ::Selenium::WebDriver::Error::InvalidSelectorError, # Work around a chromedriver go_back/go_forward race condition
226
- ::Selenium::WebDriver::Error::ElementNotInteractableError,
227
- ::Selenium::WebDriver::Error::ElementClickInterceptedError,
228
- ::Selenium::WebDriver::Error::InvalidElementStateError,
229
- ::Selenium::WebDriver::Error::ElementNotSelectableError,
230
- ::Selenium::WebDriver::Error::ElementNotSelectableError,
231
- ::Selenium::WebDriver::Error::NoSuchElementError, # IE
232
- ::Selenium::WebDriver::Error::InvalidArgumentError # IE
233
- ]
239
+ @invalid_element_errors ||= begin
240
+ [
241
+ ::Selenium::WebDriver::Error::StaleElementReferenceError,
242
+ ::Selenium::WebDriver::Error::ElementNotInteractableError,
243
+ ::Selenium::WebDriver::Error::InvalidSelectorError, # Work around chromedriver go_back/go_forward race condition
244
+ ::Selenium::WebDriver::Error::ElementClickInterceptedError,
245
+ ::Selenium::WebDriver::Error::NoSuchElementError, # IE
246
+ ::Selenium::WebDriver::Error::InvalidArgumentError # IE
247
+ ].tap do |errors|
248
+ unless selenium_4?
249
+ ::Selenium::WebDriver.logger.suppress_deprecations do
250
+ errors.concat [
251
+ ::Selenium::WebDriver::Error::UnhandledError,
252
+ ::Selenium::WebDriver::Error::ElementNotVisibleError,
253
+ ::Selenium::WebDriver::Error::InvalidElementStateError,
254
+ ::Selenium::WebDriver::Error::ElementNotSelectableError
255
+ ]
256
+ end
257
+ end
258
+ end
259
+ end
234
260
  end
235
261
 
236
262
  def no_such_window_error
@@ -239,6 +265,10 @@ class Capybara::Selenium::Driver < Capybara::Driver::Base
239
265
 
240
266
  private
241
267
 
268
+ def selenium_4?
269
+ defined?(Selenium::WebDriver::VERSION) && (Selenium::WebDriver::VERSION.to_f >= 4)
270
+ end
271
+
242
272
  def native_args(args)
243
273
  args.map { |arg| arg.is_a?(Capybara::Selenium::Node) ? arg.native : arg }
244
274
  end
@@ -246,12 +276,32 @@ private
246
276
  def clear_browser_state
247
277
  delete_all_cookies
248
278
  clear_storage
249
- rescue Selenium::WebDriver::Error::UnhandledError # rubocop:disable Lint/HandleExceptions
279
+ rescue *clear_browser_state_errors # rubocop:disable Lint/HandleExceptions
250
280
  # delete_all_cookies fails when we've previously gone
251
281
  # to about:blank, so we rescue this error and do nothing
252
282
  # instead.
253
283
  end
254
284
 
285
+ def clear_browser_state_errors
286
+ @clear_browser_state_errors ||= [Selenium::WebDriver::Error::UnknownError].tap do |errors|
287
+ unless selenium_4?
288
+ ::Selenium::WebDriver.logger.suppress_deprecations do
289
+ errors << Selenium::WebDriver::Error::UnhandledError
290
+ end
291
+ end
292
+ end
293
+ end
294
+
295
+ def unhandled_alert_errors
296
+ @unhandled_alert_errors ||= [Selenium::WebDriver::Error::UnexpectedAlertOpenError].tap do |errors|
297
+ unless selenium_4?
298
+ ::Selenium::WebDriver.logger.suppress_deprecations do
299
+ errors << Selenium::WebDriver::Error::UnhandledAlertError
300
+ end
301
+ end
302
+ end
303
+ end
304
+
255
305
  def delete_all_cookies
256
306
  @browser.manage.delete_all_cookies
257
307
  end
@@ -322,15 +372,25 @@ private
322
372
  wait.until do
323
373
  alert = @browser.switch_to.alert
324
374
  regexp = text.is_a?(Regexp) ? text : Regexp.escape(text.to_s)
325
- alert.text.match(regexp) ? alert : nil
375
+ alert.text.match?(regexp) ? alert : nil
326
376
  end
327
- rescue Selenium::WebDriver::Error::TimeOutError
377
+ rescue *find_modal_errors
328
378
  raise Capybara::ModalNotFound, "Unable to find modal dialog#{" with #{text}" if text}"
329
379
  end
330
380
  end
331
381
 
382
+ def find_modal_errors
383
+ @find_modal_errors ||= [Selenium::WebDriver::Error::TimeoutError].tap do |errors|
384
+ unless selenium_4?
385
+ ::Selenium::WebDriver.logger.suppress_deprecations do
386
+ errors << Selenium::WebDriver::Error::TimeOutError
387
+ end
388
+ end
389
+ end
390
+ end
391
+
332
392
  def silenced_unknown_error_message?(msg)
333
- silenced_unknown_error_messages.any? { |regex| msg =~ regex }
393
+ silenced_unknown_error_messages.any? { |regex| msg.match? regex }
334
394
  end
335
395
 
336
396
  def silenced_unknown_error_messages
@@ -342,7 +402,7 @@ private
342
402
  when Array
343
403
  arg.map { |arr| unwrap_script_result(arr) }
344
404
  when Hash
345
- arg.each { |key, value| arg[key] = unwrap_script_result(value) }
405
+ arg.transform_values! { |value| unwrap_script_result(value) }
346
406
  when Selenium::WebDriver::Element
347
407
  build_node(arg)
348
408
  else
@@ -358,24 +418,13 @@ private
358
418
  ::Capybara::Selenium::Node.new(self, native_node, initial_cache)
359
419
  end
360
420
 
361
- def specialize_driver(sel_driver)
362
- case sel_driver.browser
363
- when :chrome
364
- extend ChromeDriver
365
- when :firefox
366
- require 'capybara/selenium/patches/pause_duration_fix' if pause_broken?(sel_driver)
367
- extend FirefoxDriver if sel_driver.capabilities.is_a?(::Selenium::WebDriver::Remote::W3C::Capabilities)
368
- when :ie, :internet_explorer
369
- extend InternetExplorerDriver
370
- when :safari, :Safari_Technology_Preview
371
- extend SafariDriver
421
+ def specialize_driver
422
+ browser_type = browser.browser
423
+ self.class.specializations.select { |k, _v| k === browser_type }.each_value do |specialization| # rubocop:disable Style/CaseEquality
424
+ extend specialization
372
425
  end
373
426
  end
374
427
 
375
- def pause_broken?(driver)
376
- driver.capabilities['moz:geckodriverVersion']&.start_with?('0.22.')
377
- end
378
-
379
428
  def setup_exit_handler
380
429
  main = Process.pid
381
430
  at_exit do