ferrum 0.7 → 0.10.2

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.
@@ -33,13 +33,13 @@ module Ferrum
33
33
  def down(key)
34
34
  key = normalize_keys(Array(key))
35
35
  type = key[:text] ? "keyDown" : "rawKeyDown"
36
- @page.command("Input.dispatchKeyEvent", type: type, **key)
36
+ @page.command("Input.dispatchKeyEvent", slowmoable: true, type: type, **key)
37
37
  self
38
38
  end
39
39
 
40
40
  def up(key)
41
41
  key = normalize_keys(Array(key))
42
- @page.command("Input.dispatchKeyEvent", type: "keyUp", **key)
42
+ @page.command("Input.dispatchKeyEvent", slowmoable: true, type: "keyUp", **key)
43
43
  self
44
44
  end
45
45
 
@@ -49,7 +49,7 @@ module Ferrum
49
49
  keys.each do |key|
50
50
  type = key[:text] ? "keyDown" : "rawKeyDown"
51
51
  @page.command("Input.dispatchKeyEvent", type: type, **key)
52
- @page.command("Input.dispatchKeyEvent", type: "keyUp", **key)
52
+ @page.command("Input.dispatchKeyEvent", slowmoable: true, type: "keyUp", **key)
53
53
  end
54
54
 
55
55
  self
data/lib/ferrum/mouse.rb CHANGED
@@ -32,10 +32,21 @@ module Ferrum
32
32
  tap { mouse_event(type: "mouseReleased", **options) }
33
33
  end
34
34
 
35
- # FIXME: steps
36
35
  def move(x:, y:, steps: 1)
36
+ from_x, from_y = @x, @y
37
37
  @x, @y = x, y
38
- @page.command("Input.dispatchMouseEvent", type: "mouseMoved", x: @x, y: @y)
38
+
39
+ steps.times do |i|
40
+ new_x = from_x + (@x - from_x) * ((i + 1) / steps.to_f)
41
+ new_y = from_y + (@y - from_y) * ((i + 1) / steps.to_f)
42
+
43
+ @page.command("Input.dispatchMouseEvent",
44
+ slowmoable: true,
45
+ type: "mouseMoved",
46
+ x: new_x.to_i,
47
+ y: new_y.to_i)
48
+ end
49
+
39
50
  self
40
51
  end
41
52
 
@@ -45,7 +56,7 @@ module Ferrum
45
56
  button = validate_button(button)
46
57
  options = { x: @x, y: @y, type: type, button: button, clickCount: count }
47
58
  options.merge!(modifiers: modifiers) if modifiers
48
- @page.command("Input.dispatchMouseEvent", wait: wait, **options)
59
+ @page.command("Input.dispatchMouseEvent", wait: wait, slowmoable: true, **options)
49
60
  end
50
61
 
51
62
  def validate_button(button)
@@ -83,19 +83,21 @@ module Ferrum
83
83
  @page.command("Fetch.enable", handleAuthRequests: true, patterns: [pattern])
84
84
  end
85
85
 
86
- def authorize(user:, password:, type: :server)
86
+ def authorize(user:, password:, type: :server, &block)
87
87
  unless AUTHORIZE_TYPE.include?(type)
88
88
  raise ArgumentError, ":type should be in #{AUTHORIZE_TYPE}"
89
89
  end
90
90
 
91
+ if !block_given? && !@page.subscribed?("Fetch.requestPaused")
92
+ raise ArgumentError, "Block is missing, call `authorize(...) { |r| r.continue } or subscribe to `on(:request)` events before calling it"
93
+ end
94
+
91
95
  @authorized_ids ||= {}
92
96
  @authorized_ids[type] ||= []
93
97
 
94
98
  intercept
95
99
 
96
- @page.on(:request) do |request|
97
- request.continue
98
- end
100
+ @page.on(:request, &block)
99
101
 
100
102
  @page.on(:auth) do |request, index, total|
101
103
  if request.auth_challenge?(type)
@@ -157,12 +159,27 @@ module Ferrum
157
159
  end
158
160
  end
159
161
 
162
+ @page.on("Network.loadingFailed") do |params|
163
+ exchange = select(params["requestId"]).last
164
+ exchange.error ||= Network::Error.new
165
+
166
+ exchange.error.id = params["requestId"]
167
+ exchange.error.type = params["type"]
168
+ exchange.error.error_text = params["errorText"]
169
+ exchange.error.monotonic_time = params["timestamp"]
170
+ exchange.error.canceled = params["canceled"]
171
+ end
172
+
160
173
  @page.on("Log.entryAdded") do |params|
161
174
  entry = params["entry"] || {}
162
175
  if entry["source"] == "network" && entry["level"] == "error"
163
176
  exchange = select(entry["networkRequestId"]).last
164
- error = Network::Error.new(entry)
165
- exchange.error = error
177
+ exchange.error ||= Network::Error.new
178
+
179
+ exchange.error.id = entry["networkRequestId"]
180
+ exchange.error.url = entry["url"]
181
+ exchange.error.description = entry["text"]
182
+ exchange.error.timestamp = entry["timestamp"]
166
183
  end
167
184
  end
168
185
  end
@@ -3,24 +3,17 @@
3
3
  module Ferrum
4
4
  class Network
5
5
  class Error
6
- def initialize(data)
7
- @data = data
8
- end
9
-
10
- def id
11
- @data["networkRequestId"]
12
- end
13
-
14
- def url
15
- @data["url"]
16
- end
6
+ attr_writer :canceled
7
+ attr_reader :time, :timestamp
8
+ attr_accessor :id, :url, :type, :error_text, :monotonic_time, :description
17
9
 
18
- def description
19
- @data["text"]
10
+ def canceled?
11
+ @canceled
20
12
  end
21
13
 
22
- def time
23
- @time ||= Time.strptime(@data["timestamp"].to_s, "%s")
14
+ def timestamp=(value)
15
+ @timestamp = value
16
+ @time = Time.strptime((value / 1000).to_s, "%s")
24
17
  end
25
18
  end
26
19
  end
@@ -39,7 +39,7 @@ module Ferrum
39
39
  requestId: request_id,
40
40
  responseHeaders: header_array(headers),
41
41
  })
42
- options = options.merge(body: Base64.encode64(options.fetch(:body, "")).strip) if has_body
42
+ options = options.merge(body: Base64.strict_encode64(options.fetch(:body, ""))) if has_body
43
43
 
44
44
  @status = :responded
45
45
  @page.command("Fetch.fulfillRequest", **options)
data/lib/ferrum/node.rb CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  module Ferrum
4
4
  class Node
5
+ MOVING_WAIT = ENV.fetch("FERRUM_NODE_MOVING_WAIT", 0.01).to_f
6
+ MOVING_ATTEMPTS = ENV.fetch("FERRUM_NODE_MOVING_ATTEMPTS", 50).to_i
7
+
5
8
  attr_reader :page, :target_id, :node_id, :description, :tag_name
6
9
 
7
10
  def initialize(frame, target_id, node_id, description)
@@ -24,7 +27,7 @@ module Ferrum
24
27
  end
25
28
 
26
29
  def focus
27
- tap { page.command("DOM.focus", nodeId: node_id) }
30
+ tap { page.command("DOM.focus", slowmoable: true, nodeId: node_id) }
28
31
  end
29
32
 
30
33
  def blur
@@ -37,22 +40,23 @@ module Ferrum
37
40
 
38
41
  # mode: (:left | :right | :double)
39
42
  # keys: (:alt, (:ctrl | :control), (:meta | :command), :shift)
40
- # offset: { :x, :y }
41
- def click(mode: :left, keys: [], offset: {})
42
- x, y = find_position(offset[:x], offset[:y])
43
+ # offset: { :x, :y, :position (:top | :center) }
44
+ def click(mode: :left, keys: [], offset: {}, delay: 0)
45
+ x, y = find_position(**offset)
43
46
  modifiers = page.keyboard.modifiers(keys)
44
47
 
45
48
  case mode
46
49
  when :right
47
50
  page.mouse.move(x: x, y: y)
48
51
  page.mouse.down(button: :right, modifiers: modifiers)
52
+ sleep(delay)
49
53
  page.mouse.up(button: :right, modifiers: modifiers)
50
54
  when :double
51
55
  page.mouse.move(x: x, y: y)
52
56
  page.mouse.down(modifiers: modifiers, count: 2)
53
57
  page.mouse.up(modifiers: modifiers, count: 2)
54
58
  when :left
55
- page.mouse.click(x: x, y: y, modifiers: modifiers)
59
+ page.mouse.click(x: x, y: y, modifiers: modifiers, delay: delay)
56
60
  end
57
61
 
58
62
  self
@@ -63,7 +67,7 @@ module Ferrum
63
67
  end
64
68
 
65
69
  def select_file(value)
66
- page.command("DOM.setFileInputFiles", nodeId: node_id, files: Array(value))
70
+ page.command("DOM.setFileInputFiles", slowmoable: true, nodeId: node_id, files: Array(value))
67
71
  end
68
72
 
69
73
  def at_xpath(selector)
@@ -119,35 +123,75 @@ module Ferrum
119
123
  %(#<#{self.class} @target_id=#{@target_id.inspect} @node_id=#{@node_id} @description=#{@description.inspect}>)
120
124
  end
121
125
 
122
- def find_position(offset_x = nil, offset_y = nil)
123
- quads = get_content_quads
124
- offset_x, offset_y = offset_x.to_i, offset_y.to_i
126
+ def find_position(x: nil, y: nil, position: :top)
127
+ prev = get_content_quads
128
+
129
+ # FIXME: Case when a few quads returned
130
+ points = Ferrum.with_attempts(errors: NodeIsMovingError, max: MOVING_ATTEMPTS, wait: 0) do
131
+ sleep(MOVING_WAIT)
132
+ current = get_content_quads
133
+
134
+ if current != prev
135
+ error = NodeIsMovingError.new(self, prev, current)
136
+ prev = current
137
+ raise(error)
138
+ end
139
+
140
+ current
141
+ end.map { |q| to_points(q) }.first
142
+
143
+ get_position(points, x, y, position)
144
+ rescue Ferrum::BrowserError => e
145
+ return raise unless e.message&.include?("Could not compute content quads")
146
+
147
+ find_position_via_js
148
+ end
149
+
150
+ private
151
+
152
+ def find_position_via_js
153
+ [
154
+ evaluate("this.getBoundingClientRect().left + window.pageXOffset + (this.offsetWidth / 2)"), # x
155
+ evaluate("this.getBoundingClientRect().top + window.pageYOffset + (this.offsetHeight / 2)") # y
156
+ ]
157
+ end
125
158
 
126
- if offset_x > 0 || offset_y > 0
127
- point = quads.first
128
- [point[:x] + offset_x, point[:y] + offset_y]
159
+ def get_content_quads
160
+ quads = page.command("DOM.getContentQuads", nodeId: node_id)["quads"]
161
+ raise "Node is either not visible or not an HTMLElement" if quads.size == 0
162
+ quads
163
+ end
164
+
165
+ def get_position(points, offset_x, offset_y, position)
166
+ x = y = nil
167
+
168
+ if offset_x && offset_y && position == :top
169
+ point = points.first
170
+ x = point[:x] + offset_x.to_i
171
+ y = point[:y] + offset_y.to_i
129
172
  else
130
- x, y = quads.inject([0, 0]) do |memo, point|
173
+ x, y = points.inject([0, 0]) do |memo, point|
131
174
  [memo[0] + point[:x],
132
175
  memo[1] + point[:y]]
133
176
  end
134
- [x / 4, y / 4]
177
+
178
+ x = x / 4
179
+ y = y / 4
135
180
  end
136
- end
137
181
 
138
- private
182
+ if offset_x && offset_y && position == :center
183
+ x = x + offset_x.to_i
184
+ y = y + offset_y.to_i
185
+ end
139
186
 
140
- def get_content_quads
141
- result = page.command("DOM.getContentQuads", nodeId: node_id)
142
- raise "Node is either not visible or not an HTMLElement" if result["quads"].size == 0
187
+ [x, y]
188
+ end
143
189
 
144
- # FIXME: Case when a few quads returned
145
- result["quads"].map do |quad|
146
- [{x: quad[0], y: quad[1]},
147
- {x: quad[2], y: quad[3]},
148
- {x: quad[4], y: quad[5]},
149
- {x: quad[6], y: quad[7]}]
150
- end.first
190
+ def to_points(quad)
191
+ [{x: quad[0], y: quad[1]},
192
+ {x: quad[2], y: quad[3]},
193
+ {x: quad[4], y: quad[5]},
194
+ {x: quad[6], y: quad[7]}]
151
195
  end
152
196
  end
153
197
  end
data/lib/ferrum/page.rb CHANGED
@@ -31,8 +31,8 @@ module Ferrum
31
31
 
32
32
  extend Forwardable
33
33
  delegate %i[at_css at_xpath css xpath
34
- current_url current_title url title body doctype
35
- execution_id evaluate evaluate_on evaluate_async execute
34
+ current_url current_title url title body doctype set_content
35
+ execution_id evaluate evaluate_on evaluate_async execute evaluate_func
36
36
  add_script_tag add_style_tag] => :main_frame
37
37
 
38
38
  include Frames, Screenshot
@@ -44,13 +44,14 @@ module Ferrum
44
44
 
45
45
  def initialize(target_id, browser)
46
46
  @frames = {}
47
+ @main_frame = Frame.new(nil, self)
47
48
  @target_id, @browser = target_id, browser
48
49
  @event = Event.new.tap(&:set)
49
50
 
50
51
  host = @browser.process.host
51
52
  port = @browser.process.port
52
53
  ws_url = "ws://#{host}:#{port}/devtools/page/#{@target_id}"
53
- @client = Browser::Client.new(browser, ws_url, 1000)
54
+ @client = Browser::Client.new(browser, ws_url, id_starts_with: 1000)
54
55
 
55
56
  @mouse, @keyboard = Mouse.new(self), Keyboard.new(self)
56
57
  @headers, @cookies = Headers.new(self), Cookies.new(self)
@@ -64,7 +65,7 @@ module Ferrum
64
65
  @browser.timeout
65
66
  end
66
67
 
67
- def goto(url = nil)
68
+ def go_to(url = nil)
68
69
  options = { url: combine_url!(url) }
69
70
  options.merge!(referrer: referrer) if referrer
70
71
  response = command("Page.navigate", wait: GOTO_WAIT, **options)
@@ -77,9 +78,12 @@ module Ferrum
77
78
  end
78
79
  response["frameId"]
79
80
  rescue TimeoutError
80
- pendings = network.traffic.select(&:pending?).map { |e| e.request.url }
81
- raise StatusError.new(options[:url], pendings) unless pendings.empty?
81
+ if @browser.pending_connection_errors
82
+ pendings = network.traffic.select(&:pending?).map { |e| e.request.url }
83
+ raise PendingConnectionsError.new(options[:url], pendings) unless pendings.empty?
84
+ end
82
85
  end
86
+ alias goto go_to
83
87
 
84
88
  def close
85
89
  @headers.clear
@@ -88,18 +92,16 @@ module Ferrum
88
92
  end
89
93
 
90
94
  def resize(width: nil, height: nil, fullscreen: false)
91
- result = @browser.command("Browser.getWindowForTarget", targetId: @target_id)
92
- @window_id, @bounds = result.values_at("windowId", "bounds")
93
-
94
95
  if fullscreen
95
96
  width, height = document_size
96
- @browser.command("Browser.setWindowBounds", windowId: @window_id, bounds: { windowState: "fullscreen" })
97
+ set_window_bounds(windowState: "fullscreen")
97
98
  else
98
- @browser.command("Browser.setWindowBounds", windowId: @window_id, bounds: { windowState: "normal" })
99
- @browser.command("Browser.setWindowBounds", windowId: @window_id, bounds: { width: width, height: height, windowState: "normal" })
99
+ set_window_bounds(windowState: "normal")
100
+ set_window_bounds(width: width, height: height)
100
101
  end
101
102
 
102
- command("Emulation.setDeviceMetricsOverride", width: width,
103
+ command("Emulation.setDeviceMetricsOverride", slowmoable: true,
104
+ width: width,
103
105
  height: height,
104
106
  deviceScaleFactor: 1,
105
107
  mobile: false,
@@ -107,10 +109,14 @@ module Ferrum
107
109
  end
108
110
 
109
111
  def refresh
110
- command("Page.reload", wait: timeout)
112
+ command("Page.reload", wait: timeout, slowmoable: true)
111
113
  end
112
114
  alias_method :reload, :refresh
113
115
 
116
+ def stop
117
+ command("Page.stopLoading", slowmoable: true)
118
+ end
119
+
114
120
  def back
115
121
  history_navigate(delta: -1)
116
122
  end
@@ -119,15 +125,31 @@ module Ferrum
119
125
  history_navigate(delta: 1)
120
126
  end
121
127
 
128
+ def wait_for_reload(sec = 1)
129
+ @event.reset if @event.set?
130
+ @event.wait(sec)
131
+ @event.set
132
+ end
133
+
122
134
  def bypass_csp(value = true)
123
135
  enabled = !!value
124
136
  command("Page.setBypassCSP", enabled: enabled)
125
137
  enabled
126
138
  end
127
139
 
128
- def command(method, wait: 0, **params)
140
+ def window_id
141
+ @browser.command("Browser.getWindowForTarget", targetId: @target_id)["windowId"]
142
+ end
143
+
144
+ def set_window_bounds(bounds = {})
145
+ @browser.command("Browser.setWindowBounds", windowId: window_id, bounds: bounds)
146
+ end
147
+
148
+ def command(method, wait: 0, slowmoable: false, **params)
129
149
  iteration = @event.reset if wait > 0
150
+ sleep(@browser.slowmo) if slowmoable && @browser.slowmo > 0
130
151
  result = @client.command(method, params)
152
+
131
153
  if wait > 0
132
154
  @event.wait(wait) # Wait a bit after command and check if iteration has
133
155
  # changed which means there was some network event for
@@ -165,6 +187,10 @@ module Ferrum
165
187
  end
166
188
  end
167
189
 
190
+ def subscribed?(event)
191
+ @client.subscribed?(event)
192
+ end
193
+
168
194
  private
169
195
 
170
196
  def subscribe
@@ -236,7 +262,9 @@ module Ferrum
236
262
 
237
263
  if entry = entries[index + delta]
238
264
  # Potential wait because of network event
239
- command("Page.navigateToHistoryEntry", wait: Mouse::CLICK_WAIT, entryId: entry["id"])
265
+ command("Page.navigateToHistoryEntry", wait: Mouse::CLICK_WAIT,
266
+ slowmoable: true,
267
+ entryId: entry["id"])
240
268
  end
241
269
  end
242
270
 
@@ -245,7 +273,7 @@ module Ferrum
245
273
  nil_or_relative = url.nil? || url.relative?
246
274
 
247
275
  if nil_or_relative && !@browser.base_url
248
- raise "Set :base_url browser's option or use absolute url in `goto`, you passed: #{url_or_path}"
276
+ raise "Set :base_url browser's option or use absolute url in `go_to`, you passed: #{url_or_path}"
249
277
  end
250
278
 
251
279
  (nil_or_relative ? @browser.base_url.join(url.to_s) : url).to_s