cuprite 0.18 → 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,28 +144,43 @@ 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)
155
+ raise ArgumentError, "You must specify both x: and y: for a drop offset" if options[:x].nil? ^ options[:y].nil?
156
+
184
157
  options[:steps] ||= 1
185
158
  options[:scroll] = true unless options.key?(:scroll)
159
+ options[:drop_offset] = drop_offset(*options.values_at(:x, :y, :offset))
186
160
  modifiers = Array(options[:drop_modifiers]).map { |m| DRAG_MODIFIER_ALIASES.fetch(m.to_sym, m.to_sym) }
187
161
 
188
- command(:drag, other.node, modifiers, options.slice(:steps, :delay, :scroll, :html5))
162
+ command { drag(other.node, modifiers, options) }
189
163
  end
190
164
 
191
- def drag_by(x, y, **options)
192
- options[:steps] ||= 1
193
- options[:scroll] = true unless options.key?(:scroll)
194
-
195
- 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
196
176
  end
197
177
 
198
- def drop(...) = command(:drop, ...)
199
-
200
178
  def trigger(event)
201
- 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
202
184
  end
203
185
 
204
186
  def scroll_to(element, location, position = nil)
@@ -213,15 +195,7 @@ module Capybara
213
195
  end
214
196
 
215
197
  def scroll_by(x, y)
216
- driver.execute_script <<~JS, self, x, y
217
- var el = arguments[0];
218
- if (el.scrollBy){
219
- el.scrollBy(arguments[1], arguments[2]);
220
- } else {
221
- el.scrollTop = el.scrollTop + arguments[2];
222
- el.scrollLeft = el.scrollLeft + arguments[1];
223
- }
224
- JS
198
+ node.evaluate("this.scrollBy(x, y)", x: x, y: y)
225
199
  end
226
200
 
227
201
  def ==(other)
@@ -232,56 +206,121 @@ module Capybara
232
206
  keys = keys.reject { |key| key.nil? || key == "" }
233
207
  return if keys.empty?
234
208
 
235
- 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
236
217
  end
237
- alias send_key send_keys
238
218
 
239
219
  def path
240
220
  command(:path)
241
221
  end
242
222
 
243
223
  def obscured?
244
- command(:obscured?)
224
+ command(:isObscured)
245
225
  end
246
226
 
247
227
  def shadow_root
248
- root = driver.evaluate_script <<~JS, self
249
- arguments[0].shadowRoot
250
- JS
251
- root && self.class.new(driver, root.node)
228
+ root = driver.browser.evaluate("node.shadowRoot", node: node)
229
+ root && self.class.new(driver, root)
252
230
  end
253
231
 
254
232
  def rect
255
- driver.evaluate_script <<~JS, self
256
- arguments[0].getBoundingClientRect().toJSON()
257
- JS
233
+ node.evaluate("this.getBoundingClientRect().toJSON()")
258
234
  end
259
235
 
260
236
  def inspect
261
237
  %(#<#{self.class} @node=#{@node.inspect}>)
262
238
  end
263
239
 
264
- # @api private
265
- def to_json(*)
266
- JSON.generate(as_json)
267
- end
240
+ private
268
241
 
269
- # @api private
270
- def as_json(*)
271
- # FIXME: Where is this method used and why attr is called id?
272
- { 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
273
246
  end
274
247
 
275
- 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
276
255
 
277
256
  def prepare_and_click(mode, name, keys, options)
278
257
  delay = options[:delay].to_i
279
258
  x, y = options.values_at(:x, :y)
280
259
  offset = { x: x, y: y, position: options[:offset] || :top }
281
- command(:before_click, name, keys, offset)
260
+ command { before_click(name, offset) }
282
261
  node.click(mode: mode, keys: keys, offset: offset, delay: delay)
283
262
  end
284
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
+
317
+ def drop_offset(x, y, position)
318
+ return unless x
319
+
320
+ position ||= driver.session_options.w3c_click_offset ? :center : :top
321
+ { x: x, y: y, position: position.to_sym }
322
+ end
323
+
285
324
  def filter_text(text)
286
325
  text.gsub(/[\u200b\u200e\u200f]/, "")
287
326
  .gsub(/[\ \n\f\t\v\u2028\u2029]+/, " ")
@@ -301,34 +340,19 @@ module Capybara
301
340
  else
302
341
  raise ArgumentError, "Invalid scroll_to location: #{location}"
303
342
  end
304
- driver.execute_script <<~JS, element
305
- arguments[0].scrollIntoView(#{scroll_opts})
306
- JS
343
+ element.node.evaluate("this.scrollIntoView(#{scroll_opts})")
307
344
  end
308
345
 
309
346
  def scroll_to_location(location)
310
347
  height = { top: "0",
311
- bottom: "arguments[0].scrollHeight",
312
- center: "(arguments[0].scrollHeight - arguments[0].clientHeight)/2" }
348
+ bottom: "this.scrollHeight",
349
+ center: "(this.scrollHeight - this.clientHeight) / 2" }
313
350
 
314
- driver.execute_script <<~JS, self
315
- if (arguments[0].scrollTo){
316
- arguments[0].scrollTo(0, #{height[location]});
317
- } else {
318
- arguments[0].scrollTop = #{height[location]};
319
- }
320
- JS
351
+ node.evaluate("this.scrollTo(0, #{height[location]})")
321
352
  end
322
353
 
323
354
  def scroll_to_coords(x, y)
324
- driver.execute_script <<~JS, self, x, y
325
- if (arguments[0].scrollTo){
326
- arguments[0].scrollTo(arguments[1], arguments[2]);
327
- } else {
328
- arguments[0].scrollTop = arguments[2];
329
- arguments[0].scrollLeft = arguments[1];
330
- }
331
- JS
355
+ node.evaluate("this.scrollTo(x, y)", x: x, y: y)
332
356
  end
333
357
  end
334
358
  end
@@ -6,6 +6,25 @@ module Ferrum
6
6
  attr_writer :window_size
7
7
  attr_accessor :url_blacklist, :url_whitelist, :raise_on_unhandled_modal
8
8
 
9
+ def mobile
10
+ @options.fetch(:mobile, nil)
11
+ end
12
+
13
+ def scale_factor
14
+ @options.fetch(:scale_factor, 0)
15
+ end
16
+
17
+ def device_user_agent
18
+ Capybara::Cuprite::Devices.fetch(@options[:emulate])[:user_agent] if @options[:emulate]
19
+ end
20
+
21
+ def default_user_agent
22
+ return @default_user_agent unless device_user_agent
23
+
24
+ chrome_version = @default_user_agent.to_s[%r{Chrome/(\S+)}, 1].to_s
25
+ format(device_user_agent, version: chrome_version)
26
+ end
27
+
9
28
  def reset_window_size
10
29
  @window_size = @options[:window_size]
11
30
  end
@@ -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