ferrum 0.6 → 0.9

Sign up to get free protection for your applications and to get access to all the features.
@@ -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