ferrum 0.17.2 → 0.19.0

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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/ferrum/accessibility/ax_node.rb +108 -0
  4. data/lib/ferrum/accessibility.rb +106 -0
  5. data/lib/ferrum/browser/binary.rb +42 -2
  6. data/lib/ferrum/browser/command.rb +20 -2
  7. data/lib/ferrum/browser/options/base.rb +66 -0
  8. data/lib/ferrum/browser/options/chrome.rb +35 -2
  9. data/lib/ferrum/browser/options.rb +41 -2
  10. data/lib/ferrum/browser/process/killer.rb +188 -0
  11. data/lib/ferrum/browser/process.rb +128 -72
  12. data/lib/ferrum/browser/xvfb.rb +24 -0
  13. data/lib/ferrum/browser.rb +47 -17
  14. data/lib/ferrum/client/subscriber.rb +65 -2
  15. data/lib/ferrum/client/web_socket.rb +126 -13
  16. data/lib/ferrum/client.rb +225 -12
  17. data/lib/ferrum/context.rb +113 -7
  18. data/lib/ferrum/contexts.rb +188 -19
  19. data/lib/ferrum/cookies/cookie.rb +6 -0
  20. data/lib/ferrum/cookies.rb +5 -0
  21. data/lib/ferrum/dialog.rb +18 -2
  22. data/lib/ferrum/downloads.rb +54 -1
  23. data/lib/ferrum/errors.rb +103 -4
  24. data/lib/ferrum/frame/dom.rb +65 -0
  25. data/lib/ferrum/frame/runtime.rb +57 -15
  26. data/lib/ferrum/frame.rb +58 -1
  27. data/lib/ferrum/headers.rb +6 -0
  28. data/lib/ferrum/interceptable.rb +83 -0
  29. data/lib/ferrum/keyboard.rb +25 -0
  30. data/lib/ferrum/mouse.rb +6 -0
  31. data/lib/ferrum/network/auth_request.rb +81 -2
  32. data/lib/ferrum/network/error.rb +15 -0
  33. data/lib/ferrum/network/exchange.rb +10 -0
  34. data/lib/ferrum/network/intercepted_request.rb +94 -2
  35. data/lib/ferrum/network/request.rb +1 -1
  36. data/lib/ferrum/network/response.rb +2 -0
  37. data/lib/ferrum/network.rb +121 -9
  38. data/lib/ferrum/node.rb +305 -15
  39. data/lib/ferrum/page/animation.rb +5 -0
  40. data/lib/ferrum/page/frames.rb +69 -7
  41. data/lib/ferrum/page/screencast.rb +5 -0
  42. data/lib/ferrum/page/screenshot.rb +55 -23
  43. data/lib/ferrum/page/stream.rb +71 -0
  44. data/lib/ferrum/page/tracing.rb +6 -0
  45. data/lib/ferrum/page.rb +148 -45
  46. data/lib/ferrum/proxy.rb +52 -2
  47. data/lib/ferrum/rgba.rb +10 -0
  48. data/lib/ferrum/target.rb +130 -3
  49. data/lib/ferrum/utils/attempt.rb +20 -0
  50. data/lib/ferrum/utils/elapsed_time.rb +38 -0
  51. data/lib/ferrum/utils/event.rb +14 -0
  52. data/lib/ferrum/utils/platform.rb +21 -0
  53. data/lib/ferrum/utils/thread.rb +15 -1
  54. data/lib/ferrum/version.rb +1 -1
  55. data/lib/ferrum/worker.rb +125 -0
  56. data/lib/ferrum.rb +7 -0
  57. metadata +6 -16
  58. data/lib/ferrum/browser/options/firefox.rb +0 -38
data/lib/ferrum/mouse.rb CHANGED
@@ -1,6 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ferrum
4
+ #
5
+ # Simulates mouse input for a page via the CDP `Input.dispatchMouseEvent`
6
+ # command, and page scrolling via JavaScript. Keeps track of the current
7
+ # pointer position and pressed buttons so `move`, `down`, `up` and `click`
8
+ # can be composed while reporting consistent state to Chrome.
9
+ #
4
10
  class Mouse
5
11
  CLICK_WAIT = ENV.fetch("FERRUM_CLICK_WAIT", 0.1).to_f
6
12
  BUTTON_MASKS = {
@@ -2,6 +2,11 @@
2
2
 
3
3
  module Ferrum
4
4
  class Network
5
+ #
6
+ # Represents an HTTP authentication challenge (basic or digest auth
7
+ # prompt) raised by the browser via `Fetch.authRequired`, which can be
8
+ # answered with credentials or canceled.
9
+ #
5
10
  class AuthRequest
6
11
  attr_accessor :request_id, :frame_id, :resource_type
7
12
 
@@ -14,47 +19,121 @@ module Ferrum
14
19
  @request = params["request"]
15
20
  end
16
21
 
22
+ #
23
+ # Whether this request is for the navigation of a frame, as opposed
24
+ # to a subresource (script, image, XHR, etc).
25
+ #
26
+ # @return [Boolean]
27
+ #
17
28
  def navigation_request?
18
29
  @params["isNavigationRequest"]
19
30
  end
20
31
 
32
+ #
33
+ # Whether the authentication challenge's source matches the given
34
+ # type.
35
+ #
36
+ # @param [String, Symbol] source
37
+ # One of `:server` or `:proxy`.
38
+ #
39
+ # @return [Boolean]
40
+ #
21
41
  def auth_challenge?(source)
22
42
  @params.dig("authChallenge", "source")&.downcase&.to_s == source.to_s
23
43
  end
24
44
 
25
- def match?(regexp)
26
- !!url.match(regexp)
45
+ #
46
+ # Whether the request's URL matches the given pattern.
47
+ #
48
+ # @param [String, Regexp] pattern
49
+ #
50
+ # @return [Boolean]
51
+ #
52
+ def match?(pattern)
53
+ case url
54
+ when pattern
55
+ true
56
+ else
57
+ false
58
+ end
27
59
  end
28
60
 
61
+ #
62
+ # Responds to the authentication challenge, e.g. with credentials or by
63
+ # canceling it.
64
+ #
65
+ # @param [Hash] options
66
+ #
67
+ # @option options [String] :response
68
+ # One of `"Default"`, `"CancelAuth"`, or `"ProvideCredentials"`.
69
+ #
70
+ # @option options [String] :username
71
+ #
72
+ # @option options [String] :password
73
+ #
29
74
  def continue(**options)
30
75
  options = options.merge(requestId: request_id)
31
76
  @page.command("Fetch.continueWithAuth", **options)
32
77
  end
33
78
 
79
+ #
80
+ # Cancels the authentication challenge and the request.
81
+ #
34
82
  def abort
35
83
  @page.command("Fetch.failRequest", requestId: request_id, errorReason: "BlockedByClient")
36
84
  end
37
85
 
86
+ #
87
+ # The URL for the request.
88
+ #
89
+ # @return [String]
90
+ #
38
91
  def url
39
92
  @request["url"]
40
93
  end
41
94
 
95
+ #
96
+ # The request method.
97
+ #
98
+ # @return [String]
99
+ #
42
100
  def method
43
101
  @request["method"]
44
102
  end
45
103
 
104
+ #
105
+ # The request headers.
106
+ #
107
+ # @return [Hash{String => String}]
108
+ #
46
109
  def headers
47
110
  @request["headers"]
48
111
  end
49
112
 
113
+ #
114
+ # The request's initial priority, one of `"VeryLow"`, `"Low"`,
115
+ # `"Medium"`, `"High"`, or `"VeryHigh"`.
116
+ #
117
+ # @return [String]
118
+ #
50
119
  def initial_priority
51
120
  @request["initialPriority"]
52
121
  end
53
122
 
123
+ #
124
+ # The request's referrer policy.
125
+ #
126
+ # @return [String]
127
+ #
54
128
  def referrer_policy
55
129
  @request["referrerPolicy"]
56
130
  end
57
131
 
132
+ #
133
+ # Inspects the auth request.
134
+ #
135
+ # @return [String]
136
+ #
58
137
  def inspect
59
138
  "#<#{self.class} " \
60
139
  "@request_id=#{@request_id.inspect} " \
@@ -2,15 +2,30 @@
2
2
 
3
3
  module Ferrum
4
4
  class Network
5
+ #
6
+ # Represents a network-level loading error reported for a request,
7
+ # e.g. via `Network.loadingFailed`. Distinct from the Ruby exceptions
8
+ # defined in `ferrum/errors.rb`.
9
+ #
5
10
  class Error
6
11
  attr_writer :canceled
7
12
  attr_reader :time, :timestamp
8
13
  attr_accessor :id, :url, :type, :error_text, :monotonic_time, :description
9
14
 
15
+ # Whether the request was canceled.
16
+ #
17
+ # @return [Boolean]
10
18
  def canceled?
11
19
  @canceled
12
20
  end
13
21
 
22
+ # Sets the error's timestamp, deriving {#time} from it.
23
+ #
24
+ # @param [Float] value
25
+ # Timestamp in milliseconds since epoch, as reported by
26
+ # `Log.entryAdded`.
27
+ #
28
+ # @return [Float]
14
29
  def timestamp=(value)
15
30
  @timestamp = value
16
31
  @time = Time.strptime((value / 1000).to_s, "%s")
@@ -2,6 +2,11 @@
2
2
 
3
3
  module Ferrum
4
4
  class Network
5
+ #
6
+ # Pairs a {Request} with its eventual {Response} or {Error} (and, if
7
+ # interception is enabled, its {InterceptedRequest}), representing the
8
+ # full lifecycle of a single network request.
9
+ #
5
10
  class Exchange
6
11
  # ID of the request.
7
12
  #
@@ -34,6 +39,11 @@ module Ferrum
34
39
  # @return Boolean
35
40
  attr_accessor :unknown
36
41
 
42
+ # The raw `Network.requestWillBeSentExtraInfo` params for the request,
43
+ # if it arrived before `request` was set.
44
+ #
45
+ # @return [Hash, nil]
46
+ #
37
47
  # @api private
38
48
  attr_accessor :request_extra_info
39
49
 
@@ -5,6 +5,11 @@ require "base64"
5
5
 
6
6
  module Ferrum
7
7
  class Network
8
+ #
9
+ # Represents a request paused by request interception (`Fetch.enable`),
10
+ # allowing it to be continued as is or with overrides, fulfilled with a
11
+ # fake response, or aborted before it reaches the network.
12
+ #
8
13
  class InterceptedRequest
9
14
  include RequestParams
10
15
 
@@ -21,18 +26,62 @@ module Ferrum
21
26
  @network_id = params["networkId"]
22
27
  end
23
28
 
29
+ #
30
+ # Whether the request's current status matches the given value.
31
+ #
32
+ # @param [String, Symbol] value
33
+ # One of `:responded`, `:continued`, `:aborted`.
34
+ #
35
+ # @return [Boolean]
36
+ #
24
37
  def status?(value)
25
38
  @status == value.to_sym
26
39
  end
27
40
 
41
+ #
42
+ # Whether this request is for the navigation of a frame, as opposed
43
+ # to a subresource (script, image, XHR, etc).
44
+ #
45
+ # @return [Boolean]
46
+ #
28
47
  def navigation_request?
29
48
  @params["isNavigationRequest"]
30
49
  end
31
50
 
32
- def match?(regexp)
33
- !!url.match(regexp)
51
+ #
52
+ # Whether the request's URL matches the given pattern.
53
+ #
54
+ # @param [String, Regexp] pattern
55
+ #
56
+ # @return [Boolean]
57
+ #
58
+ def match?(pattern)
59
+ case url
60
+ when pattern
61
+ true
62
+ else
63
+ false
64
+ end
34
65
  end
35
66
 
67
+ #
68
+ # Fulfills the intercepted request with a fake response instead of
69
+ # letting it reach the network.
70
+ #
71
+ # @param [Hash] options
72
+ #
73
+ # @option options [Integer] :responseCode
74
+ # HTTP status code to respond with, `200` by default.
75
+ #
76
+ # @option options [String] :body
77
+ # Response body.
78
+ #
79
+ # @option options [Hash] :responseHeaders
80
+ # Response headers.
81
+ #
82
+ # @example
83
+ # request.respond(body: "Lorem ipsum")
84
+ #
36
85
  def respond(**options)
37
86
  has_body = options.key?(:body)
38
87
  headers = has_body ? { "content-length" => options.fetch(:body, "").length } : {}
@@ -46,25 +95,68 @@ module Ferrum
46
95
  @client.command("Fetch.fulfillRequest", async: true, **options)
47
96
  end
48
97
 
98
+ #
99
+ # Continues the intercepted request, letting it reach the network as
100
+ # is, or with the given overrides.
101
+ #
102
+ # @param [Hash] options
103
+ #
104
+ # @option options [String] :url
105
+ # Overrides the request's URL.
106
+ #
107
+ # @option options [String] :method
108
+ # Overrides the request's method.
109
+ #
110
+ # @option options [String] :postData
111
+ # Overrides the request's post data.
112
+ #
113
+ # @option options [Hash] :headers
114
+ # Overrides the request's headers.
115
+ #
116
+ # @example
117
+ # request.continue
118
+ #
49
119
  def continue(**options)
50
120
  options = options.merge(requestId: request_id)
51
121
  @status = :continued
52
122
  @client.command("Fetch.continueRequest", async: true, **options)
53
123
  end
54
124
 
125
+ #
126
+ # Aborts the intercepted request.
127
+ #
128
+ # @example
129
+ # request.abort
130
+ #
55
131
  def abort
56
132
  @status = :aborted
57
133
  @client.command("Fetch.failRequest", async: true, requestId: request_id, errorReason: "BlockedByClient")
58
134
  end
59
135
 
136
+ #
137
+ # The request's initial priority, one of `"VeryLow"`, `"Low"`,
138
+ # `"Medium"`, `"High"`, or `"VeryHigh"`.
139
+ #
140
+ # @return [String]
141
+ #
60
142
  def initial_priority
61
143
  @request["initialPriority"]
62
144
  end
63
145
 
146
+ #
147
+ # The request's referrer policy.
148
+ #
149
+ # @return [String]
150
+ #
64
151
  def referrer_policy
65
152
  @request["referrerPolicy"]
66
153
  end
67
154
 
155
+ #
156
+ # Inspects the intercepted request.
157
+ #
158
+ # @return [String]
159
+ #
68
160
  def inspect
69
161
  "#<#{self.class} " \
70
162
  "@request_id=#{@request_id.inspect} " \
@@ -33,7 +33,7 @@ module Ferrum
33
33
  end
34
34
 
35
35
  #
36
- # The request resouce type.
36
+ # The request resource type.
37
37
  #
38
38
  # @return [String]
39
39
  #
@@ -145,6 +145,8 @@ module Ferrum
145
145
  # nop
146
146
  end
147
147
 
148
+ #
149
+ # Whether this response is the main frame's current response.
148
150
  #
149
151
  # @return [Boolean]
150
152
  #
@@ -8,6 +8,13 @@ require "ferrum/network/request"
8
8
  require "ferrum/network/response"
9
9
 
10
10
  module Ferrum
11
+ #
12
+ # Tracks a page's network activity, exposing it as a list of {#traffic}
13
+ # {Network::Exchange}s built from the underlying CDP `Network.*` events.
14
+ # Also provides request interception/authorization (`intercept`,
15
+ # `authorize`, `blacklist=`/`whitelist=`) and network condition emulation
16
+ # (`emulate_network_conditions`, `offline_mode`).
17
+ #
11
18
  class Network
12
19
  CLEAR_TYPE = %i[traffic cache].freeze
13
20
  AUTHORIZE_TYPE = %i[server proxy].freeze
@@ -38,6 +45,7 @@ module Ferrum
38
45
  @exchange = nil
39
46
  @blacklist = nil
40
47
  @whitelist = nil
48
+ @mutex = Mutex.new
41
49
  end
42
50
 
43
51
  #
@@ -81,18 +89,45 @@ module Ferrum
81
89
  raise TimeoutError unless result
82
90
  end
83
91
 
92
+ #
93
+ # Whether the network is idle, i.e. no more than `connections`
94
+ # connections are still pending.
95
+ #
96
+ # @param [Integer] connections
97
+ # How many connections are allowed for network to be idling.
98
+ #
99
+ # @return [Boolean]
100
+ #
84
101
  def idle?(connections = 0)
85
102
  pending_connections <= connections
86
103
  end
87
104
 
105
+ #
106
+ # Total number of network connections seen since the traffic was last
107
+ # cleared.
108
+ #
109
+ # @return [Integer]
110
+ #
88
111
  def total_connections
89
112
  @traffic.size
90
113
  end
91
114
 
115
+ #
116
+ # Number of network connections that have finished, i.e. were blocked, got
117
+ # a loaded response, errored, or are otherwise no longer pending.
118
+ #
119
+ # @return [Integer]
120
+ #
92
121
  def finished_connections
93
122
  @traffic.count(&:finished?)
94
123
  end
95
124
 
125
+ #
126
+ # Number of network connections that are still pending, i.e. haven't
127
+ # finished yet.
128
+ #
129
+ # @return [Integer]
130
+ #
96
131
  def pending_connections
97
132
  total_connections - finished_connections
98
133
  end
@@ -164,12 +199,38 @@ module Ferrum
164
199
  true
165
200
  end
166
201
 
202
+ #
203
+ # Sets a list of patterns for URLs that should be blocked from loading.
204
+ # Aborts any request whose URL matches one of the given patterns, and
205
+ # continues all others. Can't be used together with `whitelist=`.
206
+ #
207
+ # @param [String, Regexp, Array<String, Regexp>] patterns
208
+ # One or more patterns matched against the request's URL, see
209
+ # {InterceptedRequest#match?}.
210
+ #
211
+ # @example
212
+ # browser.network.blacklist = /jquery/
213
+ # browser.go_to("https://example.com/")
214
+ #
167
215
  def blacklist=(patterns)
168
216
  @blacklist = Array(patterns)
169
217
  blacklist_subscribe
170
218
  end
171
219
  alias blocklist= blacklist=
172
220
 
221
+ #
222
+ # Sets a list of patterns for URLs that are the only ones allowed to load.
223
+ # Continues any request whose URL matches one of the given patterns, and
224
+ # aborts all others. Can't be used together with `blacklist=`.
225
+ #
226
+ # @param [String, Regexp, Array<String, Regexp>] patterns
227
+ # One or more patterns matched against the request's URL, see
228
+ # {InterceptedRequest#match?}.
229
+ #
230
+ # @example
231
+ # browser.network.whitelist = /example/
232
+ # browser.go_to("https://example.com/")
233
+ #
173
234
  def whitelist=(patterns)
174
235
  @whitelist = Array(patterns)
175
236
  whitelist_subscribe
@@ -268,6 +329,11 @@ module Ferrum
268
329
  end
269
330
  end
270
331
 
332
+ #
333
+ # Subscribes to the CDP events needed to keep track of `traffic`. Called
334
+ # once when the page is initialized.
335
+ #
336
+ # @api private
271
337
  def subscribe
272
338
  subscribe_request_will_be_sent
273
339
  subscribe_response_received
@@ -276,6 +342,23 @@ module Ferrum
276
342
  subscribe_log_entry_added
277
343
  end
278
344
 
345
+ #
346
+ # Builds the `authChallengeResponse` sent back to Chrome for an
347
+ # authenticated request, used by `authorize`.
348
+ #
349
+ # @param [Array<String>] ids
350
+ # Request ids that were already given credentials, to avoid an infinite
351
+ # retry loop if the credentials are rejected.
352
+ #
353
+ # @param [String] request_id
354
+ #
355
+ # @param [String, nil] username
356
+ #
357
+ # @param [String, nil] password
358
+ #
359
+ # @return [Hash, nil]
360
+ #
361
+ # @api private
279
362
  def authorized_response(ids, request_id, username, password)
280
363
  if ids.include?(request_id)
281
364
  { response: "CancelAuth" }
@@ -286,14 +369,42 @@ module Ferrum
286
369
  end
287
370
  end
288
371
 
372
+ #
373
+ # Finds the exchanges in `traffic` with the given request id.
374
+ #
375
+ # @param [String] request_id
376
+ #
377
+ # @return [Array<Exchange>]
378
+ #
379
+ # @api private
289
380
  def select(request_id)
290
381
  @traffic.select { |e| e.id == request_id }
291
382
  end
292
383
 
384
+ #
385
+ # Builds a new {Exchange} for the given request id and appends it to
386
+ # `traffic`.
387
+ #
388
+ # @param [String] id
389
+ #
390
+ # @return [Exchange]
391
+ #
392
+ # @api private
293
393
  def build_exchange(id)
294
394
  Network::Exchange.new(@page, id).tap { |e| @traffic << e }
295
395
  end
296
396
 
397
+ # `Network.requestWillBeSent` and `Fetch.requestPaused` are handled on
398
+ # separate threads (see `Client::Subscriber`), so the "find the existing
399
+ # exchange for this id or build a new one" check has to be atomic,
400
+ # otherwise both threads can race past the `select` before either has
401
+ # appended, and end up building two exchanges for the same request.
402
+ #
403
+ # @api private
404
+ def find_or_build_exchange(id)
405
+ @mutex.synchronize { select(id).last || build_exchange(id) }
406
+ end
407
+
297
408
  #
298
409
  # Activates emulation of network conditions.
299
410
  #
@@ -380,24 +491,25 @@ module Ferrum
380
491
 
381
492
  # We can build exchange in two places, here on the event or when request
382
493
  # is interrupted. So we have to be careful when to create new one. We
383
- # create new exchange only if there's no with such id or there's, but
384
- # it's filled with request which means this one is new but has response
385
- # for a redirect. So we assign response from the params to previous
386
- # exchange and build new exchange to assign this request to it.
387
- exchange = select(request.id).last
388
- exchange = build_exchange(request.id) if exchange.nil? || !exchange.blank?
494
+ # create a new exchange only if there's no with such an id or there's, but
495
+ # it's filled with request which means this one is new but has a response
496
+ # for a redirect. So we assign a response from the params to the previous
497
+ # exchange and build a new exchange to assign this request to it.
498
+ exchange = @mutex.synchronize do
499
+ ex = select(request.id).last
500
+ ex.nil? || !ex.blank? ? build_exchange(request.id) : ex
501
+ end
389
502
  request.headers.merge!(Hash(exchange.request_extra_info&.dig("headers")))
390
503
  exchange.request = request
391
504
 
392
- if exchange.navigation_request?(@page.main_frame.id)
505
+ if exchange.navigation_request?(@page.main_frame&.id)
393
506
  @exchange = exchange
394
507
  classify_pending_exchanges(exchange.loader_id)
395
508
  end
396
509
  end
397
510
 
398
511
  @page.on("Network.requestWillBeSentExtraInfo") do |params|
399
- exchange = select(params["requestId"]).last
400
- exchange ||= build_exchange(params["requestId"])
512
+ exchange = find_or_build_exchange(params["requestId"])
401
513
  exchange.request_extra_info = params
402
514
  exchange.request&.headers&.merge!(params["headers"])
403
515
  end