cuprite 0.19 → 0.20

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.
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "capybara/cuprite/deprecation"
4
+
5
+ module Capybara
6
+ module Cuprite
7
+ class Node < Capybara::Driver::Node
8
+ module Deprecations
9
+ def browser
10
+ Cuprite.deprecation_warning("#{self.class}#browser", "use page.driver.browser")
11
+ driver.browser
12
+ end
13
+
14
+ def parents
15
+ Cuprite.deprecation_warning("#{self.class}#parents", "use Capybara's ancestor")
16
+ command { driver.browser.evaluate("_cuprite.parents(node)", node: node) }.map do |parent|
17
+ self.class.new(driver, parent)
18
+ end
19
+ end
20
+
21
+ def property(name)
22
+ Cuprite.deprecation_warning("#{self.class}#property", "use node.native.node.property")
23
+ command { node.property(name) }
24
+ end
25
+
26
+ def attributes
27
+ Cuprite.deprecation_warning("#{self.class}#attributes")
28
+ JSON.parse(command(:getAttributes))
29
+ end
30
+
31
+ def description
32
+ Cuprite.deprecation_warning("#{self.class}#description", "use node.native.node.description")
33
+ node.description
34
+ end
35
+
36
+ def send_key(*keys)
37
+ Cuprite.deprecation_warning("#{self.class}#send_key", "use #send_keys")
38
+ send_keys(*keys)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Cuprite
5
+ class Node < Capybara::Driver::Node
6
+ module Extensions
7
+ def drag_by(x, y, steps: 1, delay: nil, scroll: true)
8
+ mouse = driver.browser.mouse
9
+
10
+ command do
11
+ x1, y1 = node.find_position
12
+ mouse.move(x: x1, y: y1)
13
+ mouse.down
14
+
15
+ sleep delay if delay
16
+
17
+ mouse.scroll_by(x, y) if scroll
18
+
19
+ x2, y2 = node.find_position
20
+ mouse.move(x: x2 + x, y: y2 + y, steps: steps)
21
+ mouse.up
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,45 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "forwardable"
3
+ require "capybara/cuprite/finder"
4
+ require "capybara/cuprite/node/deprecations"
5
+ require "capybara/cuprite/node/extensions"
4
6
 
5
7
  module Capybara
6
8
  module Cuprite
7
9
  class Node < Capybara::Driver::Node
8
10
  attr_reader :node
9
11
 
10
- extend Forwardable
11
-
12
- delegate %i[description] => :node
13
- delegate %i[browser] => :driver
12
+ include Deprecations
13
+ include Extensions
14
14
 
15
15
  DRAG_MODIFIER_ALIASES = { control: :ctrl, command: :meta, cmd: :meta }.freeze
16
+ DRAG_HTML5_JS = File.read(File.expand_path("javascripts/drag.js", __dir__))
17
+ TRIGGER_CLICK_WAIT = ENV.fetch("CUPRITE_TRIGGER_CLICK_WAIT", 0.1).to_f
16
18
 
17
19
  def initialize(driver, node)
18
20
  super(driver, self)
19
21
  @node = node
20
22
  end
21
23
 
22
- def command(name, *args)
23
- raise ObsoleteNode.new(self, nil) unless node.evaluate("this.isConnected")
24
-
25
- browser.send(name, node, *args)
26
- rescue Ferrum::NodeNotFoundError => e
27
- raise ObsoleteNode.new(self, e.response)
28
- rescue Ferrum::BrowserError => e
29
- case e.message
30
- when "Cuprite.MouseEventFailed"
31
- raise MouseEventFailed.new(self, e.response)
32
- else
33
- raise
34
- end
35
- end
36
-
37
- def parents
38
- command(:parents).map do |parent|
39
- self.class.new(driver, parent)
40
- end
41
- end
42
-
43
24
  def find_xpath(selector)
44
25
  find(:xpath, selector)
45
26
  end
@@ -48,26 +29,16 @@ module Capybara
48
29
  find(:css, selector)
49
30
  end
50
31
 
51
- def find(method, selector)
52
- command(:find_within, method, selector).map do |node|
53
- self.class.new(driver, node)
54
- end
55
- end
56
-
57
32
  def all_text
58
- filter_text(command(:all_text))
33
+ filter_text(command { node.text })
59
34
  end
60
35
 
61
36
  def visible_text
62
- command(:visible_text).to_s
63
- .gsub(/\A[[:space:]&&[^\u00a0]]+/, "")
64
- .gsub(/[[:space:]&&[^\u00a0]]+\z/, "")
65
- .gsub(/\n+/, "\n")
66
- .tr("\u00a0", " ")
67
- end
68
-
69
- def property(name)
70
- command(:property, name)
37
+ command(:visibleText).to_s
38
+ .gsub(/\A[[:space:]&&[^\u00a0]]+/, "")
39
+ .gsub(/[[:space:]&&[^\u00a0]]+\z/, "")
40
+ .gsub(/\n+/, "\n")
41
+ .tr("\u00a0", " ")
71
42
  end
72
43
 
73
44
  def [](name)
@@ -76,17 +47,13 @@ module Capybara
76
47
  if (tag_name == "img" && name == "src") ||
77
48
  (tag_name == "a" && name == "href")
78
49
  # if attribute exists get the property
79
- return command(:attribute, name) && command(:property, name)
50
+ return command(:getAttribute, name) && command { node.property(name) }
80
51
  end
81
52
 
82
- value = property(name)
83
- value = command(:attribute, name) if value.nil? || value.is_a?(Hash)
84
-
85
- value
86
- end
87
-
88
- def attributes
89
- command(:attributes)
53
+ case (value = command { node.property(name) })
54
+ when String, Numeric, true, false then value
55
+ else command(:getAttribute, name)
56
+ end
90
57
  end
91
58
 
92
59
  def value
@@ -104,9 +71,9 @@ module Capybara
104
71
  click if value != checked?
105
72
  when "file"
106
73
  files = value.respond_to?(:to_ary) ? value.to_ary.map(&:to_s) : value.to_s
107
- command(:select_file, files)
74
+ command { node.select_file(files) }
108
75
  when "color"
109
- node.evaluate("this.setAttribute('value', '#{value}')")
76
+ node.evaluate("this.setAttribute('value', value)", value: value)
110
77
  node.evaluate("this.dispatchEvent(new InputEvent('input'))")
111
78
  node.evaluate("this.dispatchEvent(new Event('change', { bubbles: true }))")
112
79
  when "date"
@@ -130,7 +97,7 @@ module Capybara
130
97
  elsif tag_name == "textarea"
131
98
  command(:set, value.to_s)
132
99
  elsif self[:isContentEditable]
133
- command(:delete_text)
100
+ command(:deleteText)
134
101
  click.type(value.to_s)
135
102
  end
136
103
  end
@@ -145,11 +112,11 @@ module Capybara
145
112
  end
146
113
 
147
114
  def tag_name
148
- @tag_name ||= description["shadowRootType"] ? "ShadowRoot" : description["nodeName"].downcase
115
+ @tag_name ||= node.description["shadowRootType"] ? "ShadowRoot" : node.description["nodeName"].downcase
149
116
  end
150
117
 
151
118
  def visible?
152
- command(:visible?)
119
+ command(:isVisible)
153
120
  end
154
121
 
155
122
  def checked?
@@ -161,7 +128,7 @@ module Capybara
161
128
  end
162
129
 
163
130
  def disabled?
164
- command(:disabled?)
131
+ command(:isDisabled)
165
132
  end
166
133
 
167
134
  def click(keys = [], **options)
@@ -177,7 +144,11 @@ module Capybara
177
144
  end
178
145
 
179
146
  def hover
180
- command(:hover)
147
+ command do
148
+ js(:scrollIntoViewport)
149
+ x, y = find_position
150
+ driver.browser.page.command("Input.dispatchMouseEvent", type: "mouseMoved", x: x, y: y)
151
+ end
181
152
  end
182
153
 
183
154
  def drag_to(other, **options)
@@ -188,20 +159,28 @@ module Capybara
188
159
  options[:drop_offset] = drop_offset(*options.values_at(:x, :y, :offset))
189
160
  modifiers = Array(options[:drop_modifiers]).map { |m| DRAG_MODIFIER_ALIASES.fetch(m.to_sym, m.to_sym) }
190
161
 
191
- command(:drag, other.node, modifiers, options.slice(:steps, :delay, :scroll, :html5, :drop_offset))
162
+ command { drag(other.node, modifiers, options) }
192
163
  end
193
164
 
194
- def drag_by(x, y, **options)
195
- options[:steps] ||= 1
196
- options[:scroll] = true unless options.key?(:scroll)
197
-
198
- command(:drag_by, x, y, options[:steps], options[:delay], options[:scroll])
165
+ def drop(*args)
166
+ command do
167
+ if args[0].is_a?(String)
168
+ driver.browser.execute("_cuprite.attachDropInput()")
169
+ driver.browser.at_css("#_cuprite_drop_file").select_file(args)
170
+ js(:dropFile)
171
+ else
172
+ strings = args.flat_map { |arg| arg.map { |type, data| { "type" => type, "data" => data } } }
173
+ node.evaluate("_cuprite.dropString(strings, this)", strings: strings)
174
+ end
175
+ end
199
176
  end
200
177
 
201
- def drop(...) = command(:drop, ...)
202
-
203
178
  def trigger(event)
204
- command(:trigger, event)
179
+ command do
180
+ next js(:trigger, event.to_s) unless event.to_s == "click" && TRIGGER_CLICK_WAIT.positive?
181
+
182
+ driver.browser.wait_for_navigation!(TRIGGER_CLICK_WAIT) { js(:trigger, "click") }
183
+ end
205
184
  end
206
185
 
207
186
  def scroll_to(element, location, position = nil)
@@ -216,15 +195,7 @@ module Capybara
216
195
  end
217
196
 
218
197
  def scroll_by(x, y)
219
- driver.execute_script <<~JS, self, x, y
220
- var el = arguments[0];
221
- if (el.scrollBy){
222
- el.scrollBy(arguments[1], arguments[2]);
223
- } else {
224
- el.scrollTop = el.scrollTop + arguments[2];
225
- el.scrollLeft = el.scrollLeft + arguments[1];
226
- }
227
- JS
198
+ node.evaluate("this.scrollBy(x, y)", x: x, y: y)
228
199
  end
229
200
 
230
201
  def ==(other)
@@ -235,56 +206,114 @@ module Capybara
235
206
  keys = keys.reject { |key| key.nil? || key == "" }
236
207
  return if keys.empty?
237
208
 
238
- command(:send_keys, keys)
209
+ command do
210
+ unless node.evaluate("_cuprite.containsSelection(this) || _cuprite.hasFocus(this)")
211
+ before_click("click")
212
+ node.click(mode: :left, keys: keys)
213
+ end
214
+
215
+ driver.browser.keyboard.type(keys)
216
+ end
239
217
  end
240
- alias send_key send_keys
241
218
 
242
219
  def path
243
220
  command(:path)
244
221
  end
245
222
 
246
223
  def obscured?
247
- command(:obscured?)
224
+ command(:isObscured)
248
225
  end
249
226
 
250
227
  def shadow_root
251
- root = driver.evaluate_script <<~JS, self
252
- arguments[0].shadowRoot
253
- JS
254
- root && self.class.new(driver, root.node)
228
+ root = driver.browser.evaluate("node.shadowRoot", node: node)
229
+ root && self.class.new(driver, root)
255
230
  end
256
231
 
257
232
  def rect
258
- driver.evaluate_script <<~JS, self
259
- arguments[0].getBoundingClientRect().toJSON()
260
- JS
233
+ node.evaluate("this.getBoundingClientRect().toJSON()")
261
234
  end
262
235
 
263
236
  def inspect
264
237
  %(#<#{self.class} @node=#{@node.inspect}>)
265
238
  end
266
239
 
267
- # @api private
268
- def to_json(*)
269
- JSON.generate(as_json)
270
- end
240
+ private
271
241
 
272
- # @api private
273
- def as_json(*)
274
- # FIXME: Where is this method used and why attr is called id?
275
- { ELEMENT: { node: node, id: node.node_id } }
242
+ def find(method, selector)
243
+ command { Finder.find(driver.browser, method, selector, node) }.map do |node|
244
+ self.class.new(driver, node)
245
+ end
276
246
  end
277
247
 
278
- private
248
+ def command(function = nil, *args)
249
+ raise ObsoleteNode.new(self, nil) unless node.evaluate("this.isConnected")
250
+
251
+ block_given? ? yield : js(function, *args)
252
+ rescue Ferrum::NodeNotFoundError => e
253
+ raise ObsoleteNode.new(self, e.response)
254
+ end
279
255
 
280
256
  def prepare_and_click(mode, name, keys, options)
281
257
  delay = options[:delay].to_i
282
258
  x, y = options.values_at(:x, :y)
283
259
  offset = { x: x, y: y, position: options[:offset] || :top }
284
- command(:before_click, name, keys, offset)
260
+ command { before_click(name, offset) }
285
261
  node.click(mode: mode, keys: keys, offset: offset, delay: delay)
286
262
  end
287
263
 
264
+ def js(function, *args)
265
+ node.evaluate("_cuprite.#{function}(this, ...arguments)", *args)
266
+ end
267
+
268
+ def before_click(name, offset = {})
269
+ js(:scrollIntoViewport)
270
+
271
+ # If offset is given it may go outside of the element and likely error
272
+ # will be raised that we detected another element at this position.
273
+ return if offset[:x] || offset[:y]
274
+
275
+ x, y = find_position(**offset)
276
+ js(:mouseEventTest, name.to_s, x, y)
277
+ rescue Ferrum::JavaScriptError => e
278
+ raise MouseEventFailed, e.message if e.class_name == "MouseEventFailed"
279
+
280
+ raise
281
+ end
282
+
283
+ def find_position(**options)
284
+ node.find_position(**options)
285
+ rescue Ferrum::BrowserError => e
286
+ raise MouseEventFailed, "MouseEventFailed: click, none, 0, 0" if e.message == "Could not compute content quads."
287
+
288
+ raise
289
+ end
290
+
291
+ def drag(other, modifiers, options)
292
+ browser = driver.browser
293
+ delay, scroll, html5, drop_offset = options.values_at(:delay, :scroll, :html5, :drop_offset)
294
+ browser.execute("_cuprite.dragMousedownTracker()")
295
+
296
+ node.scroll_into_view if scroll
297
+ x1, y1 = node.find_position
298
+
299
+ browser.mouse.move(x: x1, y: y1)
300
+ browser.mouse.down
301
+ sleep delay if delay
302
+
303
+ html5 = !js(:legacyDragCheck) if html5.nil?
304
+
305
+ if html5
306
+ keys = modifiers.map(&:to_s)
307
+ driver.evaluate_async_script(DRAG_HTML5_JS, node, other, (delay || 0.05) * 1000, keys, drop_offset)
308
+ browser.mouse.up
309
+ else
310
+ other.scroll_into_view if scroll
311
+ x2, y2 = other.find_position(**drop_offset.to_h)
312
+
313
+ browser.keyboard.hold(*modifiers) { browser.mouse.move(x: x2, y: y2, steps: options[:steps]).up }
314
+ end
315
+ end
316
+
288
317
  def drop_offset(x, y, position)
289
318
  return unless x
290
319
 
@@ -311,34 +340,19 @@ module Capybara
311
340
  else
312
341
  raise ArgumentError, "Invalid scroll_to location: #{location}"
313
342
  end
314
- driver.execute_script <<~JS, element
315
- arguments[0].scrollIntoView(#{scroll_opts})
316
- JS
343
+ element.node.evaluate("this.scrollIntoView(#{scroll_opts})")
317
344
  end
318
345
 
319
346
  def scroll_to_location(location)
320
347
  height = { top: "0",
321
- bottom: "arguments[0].scrollHeight",
322
- center: "(arguments[0].scrollHeight - arguments[0].clientHeight)/2" }
348
+ bottom: "this.scrollHeight",
349
+ center: "(this.scrollHeight - this.clientHeight) / 2" }
323
350
 
324
- driver.execute_script <<~JS, self
325
- if (arguments[0].scrollTo){
326
- arguments[0].scrollTo(0, #{height[location]});
327
- } else {
328
- arguments[0].scrollTop = #{height[location]};
329
- }
330
- JS
351
+ node.evaluate("this.scrollTo(0, #{height[location]})")
331
352
  end
332
353
 
333
354
  def scroll_to_coords(x, y)
334
- driver.execute_script <<~JS, self, x, y
335
- if (arguments[0].scrollTo){
336
- arguments[0].scrollTo(arguments[1], arguments[2]);
337
- } else {
338
- arguments[0].scrollTop = arguments[2];
339
- arguments[0].scrollLeft = arguments[1];
340
- }
341
- JS
355
+ node.evaluate("this.scrollTo(x, y)", x: x, y: y)
342
356
  end
343
357
  end
344
358
  end
@@ -7,7 +7,7 @@ module Ferrum
7
7
  attr_accessor :url_blacklist, :url_whitelist, :raise_on_unhandled_modal
8
8
 
9
9
  def mobile
10
- @options.fetch(:mobile, false)
10
+ @options.fetch(:mobile, nil)
11
11
  end
12
12
 
13
13
  def scale_factor
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Capybara
6
+ module Cuprite
7
+ class Page < Ferrum::Page
8
+ module Frames
9
+ extend Forwardable
10
+
11
+ delegate %i[at_css at_xpath css xpath
12
+ current_url current_title body execution_id execution_id!
13
+ evaluate execute] => :active_frame
14
+
15
+ def initialize(...)
16
+ @frame_stack = []
17
+ super
18
+ end
19
+
20
+ def switch_to_frame(handle)
21
+ case handle
22
+ when :parent
23
+ @frame_stack.pop
24
+ when :top
25
+ @frame_stack = []
26
+ else
27
+ @frame_stack << handle
28
+ end
29
+ end
30
+
31
+ def title
32
+ active_frame.current_title
33
+ end
34
+
35
+ private
36
+
37
+ def active_frame
38
+ if @frame_stack.empty?
39
+ main_frame
40
+ else
41
+ @frames[@frame_stack.last]
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Cuprite
5
+ class Page < Ferrum::Page
6
+ module Modals
7
+ MODAL_WAIT = ENV.fetch("CUPRITE_MODAL_WAIT", 0.05).to_f
8
+
9
+ def initialize(...)
10
+ @accept_modal = []
11
+ @modal_messages = []
12
+ @modal_response = nil
13
+ @unhandled_modal_error = nil
14
+ super
15
+ end
16
+
17
+ # The `Page.javascriptDialogOpening` event is handled on Ferrum's
18
+ # background CDP dispatcher thread, so raising there wouldn't reach
19
+ # the caller and would permanently kill that thread instead. The
20
+ # dialog is always accepted immediately from that thread, and the
21
+ # error (if any) is stashed here to be raised from the main thread
22
+ # the next time it makes a command round trip.
23
+ def command(...)
24
+ raise_pending_unhandled_modal_error! { super }
25
+ end
26
+
27
+ def accept_confirm
28
+ @accept_modal << true
29
+ end
30
+
31
+ def dismiss_confirm
32
+ @accept_modal << false
33
+ end
34
+
35
+ def accept_prompt(modal_response)
36
+ @accept_modal << true
37
+ @modal_response = modal_response
38
+ end
39
+
40
+ def dismiss_prompt
41
+ @accept_modal << false
42
+ end
43
+
44
+ def find_modal(options)
45
+ start = Ferrum::Utils::ElapsedTime.monotonic_time
46
+ expect_text = options[:text]
47
+ expect_regexp = expect_text.is_a?(Regexp) ? expect_text : Regexp.escape(expect_text.to_s)
48
+ not_found_msg = "Unable to find modal dialog"
49
+ not_found_msg += " with #{expect_text}" if expect_text
50
+ wait = options.fetch(:wait, timeout)
51
+
52
+ begin
53
+ modal_text = @modal_messages.shift
54
+ raise Capybara::ModalNotFound if modal_text.nil? || (expect_text && !modal_text.match(expect_regexp))
55
+ rescue Capybara::ModalNotFound => e
56
+ raise e, not_found_msg if Ferrum::Utils::ElapsedTime.timeout?(start, wait)
57
+
58
+ sleep(MODAL_WAIT)
59
+ retry
60
+ end
61
+
62
+ modal_text
63
+ end
64
+
65
+ private
66
+
67
+ def raise_pending_unhandled_modal_error!
68
+ error = @unhandled_modal_error
69
+ @unhandled_modal_error = nil
70
+ raise error if error
71
+
72
+ yield
73
+ ensure
74
+ error = @unhandled_modal_error
75
+ @unhandled_modal_error = nil
76
+ raise error if error
77
+ end
78
+
79
+ def prepare_page
80
+ super
81
+ on("Page.javascriptDialogOpening") { |params| handle_javascript_dialog(params) }
82
+ end
83
+
84
+ def handle_javascript_dialog(params)
85
+ accept_modal = @accept_modal.last
86
+ unhandled_modal_error = nil
87
+
88
+ if [true, false].include?(accept_modal)
89
+ @accept_modal.pop
90
+ @modal_messages << params["message"]
91
+ options = { accept: accept_modal }
92
+ response = @modal_response || params["defaultPrompt"]
93
+ else
94
+ with_text = params["message"] ? "with text `#{params['message']}` " : ""
95
+ message = "Modal window #{with_text}has been opened, but you didn't wrap " \
96
+ "your code into (`accept_prompt` | `dismiss_prompt` | " \
97
+ "`accept_confirm` | `dismiss_confirm` | `accept_alert`), " \
98
+ "accepting by default"
99
+
100
+ if @options.raise_on_unhandled_modal
101
+ unhandled_modal_error = UnhandledModalError.new(message)
102
+ else
103
+ warn message
104
+ end
105
+
106
+ options = { accept: true }
107
+ response = params["defaultPrompt"]
108
+ end
109
+ options.merge!(promptText: response) if response
110
+ # Skips #command so a pending error isn't raised on the dispatcher thread.
111
+ client.command("Page.handleJavaScriptDialog", timeout: timeout, **options)
112
+
113
+ @unhandled_modal_error = unhandled_modal_error if unhandled_modal_error
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end