ferrum 0.6 → 0.9

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
@@ -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)
@@ -3,6 +3,9 @@
3
3
  require "ferrum/network/exchange"
4
4
  require "ferrum/network/intercepted_request"
5
5
  require "ferrum/network/auth_request"
6
+ require "ferrum/network/error"
7
+ require "ferrum/network/request"
8
+ require "ferrum/network/response"
6
9
 
7
10
  module Ferrum
8
11
  class Network
@@ -20,6 +23,31 @@ module Ferrum
20
23
  @exchange = nil
21
24
  end
22
25
 
26
+ def wait_for_idle(connections: 0, duration: 0.05, timeout: @page.browser.timeout)
27
+ start = Ferrum.monotonic_time
28
+
29
+ until idle?(connections)
30
+ raise TimeoutError if Ferrum.timeout?(start, timeout)
31
+ sleep(duration)
32
+ end
33
+ end
34
+
35
+ def idle?(connections = 0)
36
+ pending_connections <= connections
37
+ end
38
+
39
+ def total_connections
40
+ @traffic.size
41
+ end
42
+
43
+ def finished_connections
44
+ @traffic.count(&:finished?)
45
+ end
46
+
47
+ def pending_connections
48
+ total_connections - finished_connections
49
+ end
50
+
23
51
  def request
24
52
  @exchange&.request
25
53
  end
@@ -87,27 +115,43 @@ module Ferrum
87
115
 
88
116
  def subscribe
89
117
  @page.on("Network.requestWillBeSent") do |params|
118
+ request = Network::Request.new(params)
119
+
120
+ # We can build exchange in two places, here on the event or when request
121
+ # is interrupted. So we have to be careful when to create new one. We
122
+ # create new exchange only if there's no with such id or there's but
123
+ # it's filled with request which means this one is new but has response
124
+ # for a redirect. So we assign response from the params to previous
125
+ # exchange and build new exchange to assign this request to it.
126
+ exchange = select(request.id).last
127
+ exchange = build_exchange(request.id) unless exchange&.blank?
128
+
90
129
  # On redirects Chrome doesn't change `requestId` and there's no
91
130
  # `Network.responseReceived` event for such request. If there's already
92
131
  # exchange object with this id then we got redirected and params has
93
132
  # `redirectResponse` key which contains the response.
94
- if exchange = first_by(params["requestId"])
95
- exchange.build_response(params)
133
+ if params["redirectResponse"]
134
+ previous_exchange = select(request.id)[-2]
135
+ response = Network::Response.new(@page, params)
136
+ previous_exchange.response = response
96
137
  end
97
138
 
98
- exchange = Network::Exchange.new(@page, params)
99
- @exchange = exchange if exchange.navigation_request?(@page.main_frame.id)
100
- @traffic << exchange
139
+ exchange.request = request
140
+
141
+ if exchange.navigation_request?(@page.main_frame.id)
142
+ @exchange = exchange
143
+ end
101
144
  end
102
145
 
103
146
  @page.on("Network.responseReceived") do |params|
104
- if exchange = last_by(params["requestId"])
105
- exchange.build_response(params)
147
+ if exchange = select(params["requestId"]).last
148
+ response = Network::Response.new(@page, params)
149
+ exchange.response = response
106
150
  end
107
151
  end
108
152
 
109
153
  @page.on("Network.loadingFinished") do |params|
110
- exchange = last_by(params["requestId"])
154
+ exchange = select(params["requestId"]).last
111
155
  if exchange && exchange.response
112
156
  exchange.response.body_size = params["encodedDataLength"]
113
157
  end
@@ -115,10 +159,10 @@ module Ferrum
115
159
 
116
160
  @page.on("Log.entryAdded") do |params|
117
161
  entry = params["entry"] || {}
118
- if entry["source"] == "network" &&
119
- entry["level"] == "error" &&
120
- exchange = last_by(entry["networkRequestId"])
121
- exchange.build_error(entry)
162
+ if entry["source"] == "network" && entry["level"] == "error"
163
+ exchange = select(entry["networkRequestId"]).last
164
+ error = Network::Error.new(entry)
165
+ exchange.error = error
122
166
  end
123
167
  end
124
168
  end
@@ -135,12 +179,12 @@ module Ferrum
135
179
  end
136
180
  end
137
181
 
138
- def first_by(request_id)
139
- @traffic.find { |e| e.request.id == request_id }
182
+ def select(request_id)
183
+ @traffic.select { |e| e.id == request_id }
140
184
  end
141
185
 
142
- def last_by(request_id)
143
- @traffic.select { |e| e.request.id == request_id }.last
186
+ def build_exchange(id)
187
+ Network::Exchange.new(@page, id).tap { |e| @traffic << e }
144
188
  end
145
189
  end
146
190
  end
@@ -1,39 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ferrum/network/error"
4
- require "ferrum/network/request"
5
- require "ferrum/network/response"
6
-
7
3
  module Ferrum
8
4
  class Network
9
5
  class Exchange
10
- attr_reader :request, :response, :error
6
+ attr_reader :id
7
+ attr_accessor :intercepted_request
8
+ attr_accessor :request, :response, :error
11
9
 
12
- def initialize(page, params)
13
- @page = page
14
- @response = @error = nil
15
- build_request(params)
10
+ def initialize(page, id)
11
+ @page, @id = page, id
12
+ @intercepted_request = nil
13
+ @request = @response = @error = nil
16
14
  end
17
15
 
18
- def build_request(params)
19
- @request = Network::Request.new(params)
16
+ def navigation_request?(frame_id)
17
+ request.type?(:document) &&
18
+ request.frame_id == frame_id
20
19
  end
21
20
 
22
- def build_response(params)
23
- @response = Network::Response.new(@page, params)
21
+ def blank?
22
+ !request
24
23
  end
25
24
 
26
- def build_error(params)
27
- @error = Network::Error.new(params)
25
+ def blocked?
26
+ intercepted_request && intercepted_request.status?(:aborted)
28
27
  end
29
28
 
30
- def navigation_request?(frame_id)
31
- request.type?(:document) &&
32
- request.frame_id == frame_id
29
+ def finished?
30
+ blocked? || response || error
33
31
  end
34
32
 
35
- def blocked?
36
- response.nil?
33
+ def pending?
34
+ !finished?
37
35
  end
38
36
 
39
37
  def to_a
@@ -41,7 +39,12 @@ module Ferrum
41
39
  end
42
40
 
43
41
  def inspect
44
- %(#<#{self.class} @id=#{@id.inspect} @request=#{@request.inspect} @response=#{@response.inspect} @error=#{@error.inspect}>)
42
+ "#<#{self.class} "\
43
+ "@id=#{@id.inspect} "\
44
+ "@intercepted_request=#{@intercepted_request.inspect} "\
45
+ "@request=#{@request.inspect} "\
46
+ "@response=#{@response.inspect} "\
47
+ "@error=#{@error.inspect}>"
45
48
  end
46
49
  end
47
50
  end
@@ -5,14 +5,20 @@ require "base64"
5
5
  module Ferrum
6
6
  class Network
7
7
  class InterceptedRequest
8
- attr_accessor :request_id, :frame_id, :resource_type
8
+ attr_accessor :request_id, :frame_id, :resource_type, :network_id, :status
9
9
 
10
10
  def initialize(page, params)
11
+ @status = nil
11
12
  @page, @params = page, params
12
13
  @request_id = params["requestId"]
13
14
  @frame_id = params["frameId"]
14
15
  @resource_type = params["resourceType"]
15
16
  @request = params["request"]
17
+ @network_id = params["networkId"]
18
+ end
19
+
20
+ def status?(value)
21
+ @status == value.to_sym
16
22
  end
17
23
 
18
24
  def navigation_request?
@@ -25,7 +31,7 @@ module Ferrum
25
31
 
26
32
  def respond(**options)
27
33
  has_body = options.has_key?(:body)
28
- headers = has_body ? { "content-length" => options.fetch(:body, '').length } : {}
34
+ headers = has_body ? { "content-length" => options.fetch(:body, "").length } : {}
29
35
  headers = headers.merge(options.fetch(:responseHeaders, {}))
30
36
 
31
37
  options = {responseCode: 200}.merge(options)
@@ -33,17 +39,20 @@ module Ferrum
33
39
  requestId: request_id,
34
40
  responseHeaders: header_array(headers),
35
41
  })
36
- 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
37
43
 
44
+ @status = :responded
38
45
  @page.command("Fetch.fulfillRequest", **options)
39
46
  end
40
47
 
41
48
  def continue(**options)
42
49
  options = options.merge(requestId: request_id)
50
+ @status = :continued
43
51
  @page.command("Fetch.continueRequest", **options)
44
52
  end
45
53
 
46
54
  def abort
55
+ @status = :aborted
47
56
  @page.command("Fetch.failRequest", requestId: request_id, errorReason: "BlockedByClient")
48
57
  end
49
58
 
@@ -34,6 +34,10 @@ module Ferrum
34
34
  def headers_size
35
35
  @response["encodedDataLength"]
36
36
  end
37
+
38
+ def type
39
+ @params["type"]
40
+ end
37
41
 
38
42
  def content_type
39
43
  @content_type ||= headers.find { |k, _| k.downcase == "content-type" }&.last&.sub(/;.*\z/, "")
@@ -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,64 @@ 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
+ end
145
+
146
+ private
147
+
148
+ def get_content_quads
149
+ quads = page.command("DOM.getContentQuads", nodeId: node_id)["quads"]
150
+ raise "Node is either not visible or not an HTMLElement" if quads.size == 0
151
+ quads
152
+ end
153
+
154
+ def get_position(points, offset_x, offset_y, position)
155
+ x = y = nil
125
156
 
126
- if offset_x > 0 || offset_y > 0
127
- point = quads.first
128
- [point[:x] + offset_x, point[:y] + offset_y]
157
+ if offset_x && offset_y && position == :top
158
+ point = points.first
159
+ x = point[:x] + offset_x.to_i
160
+ y = point[:y] + offset_y.to_i
129
161
  else
130
- x, y = quads.inject([0, 0]) do |memo, point|
162
+ x, y = points.inject([0, 0]) do |memo, point|
131
163
  [memo[0] + point[:x],
132
164
  memo[1] + point[:y]]
133
165
  end
134
- [x / 4, y / 4]
166
+
167
+ x = x / 4
168
+ y = y / 4
135
169
  end
136
- end
137
170
 
138
- private
171
+ if offset_x && offset_y && position == :center
172
+ x = x + offset_x.to_i
173
+ y = y + offset_y.to_i
174
+ end
139
175
 
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
176
+ [x, y]
177
+ end
143
178
 
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
179
+ def to_points(quad)
180
+ [{x: quad[0], y: quad[1]},
181
+ {x: quad[2], y: quad[3]},
182
+ {x: quad[4], y: quad[5]},
183
+ {x: quad[6], y: quad[7]}]
151
184
  end
152
185
  end
153
186
  end