ferrum 0.17.1 → 0.18.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/LICENSE +1 -1
  3. data/README.md +9 -1369
  4. data/lib/ferrum/accessibility/ax_node.rb +108 -0
  5. data/lib/ferrum/accessibility.rb +106 -0
  6. data/lib/ferrum/browser/binary.rb +41 -0
  7. data/lib/ferrum/browser/command.rb +20 -0
  8. data/lib/ferrum/browser/options/base.rb +69 -2
  9. data/lib/ferrum/browser/options/chrome.rb +88 -26
  10. data/lib/ferrum/browser/options/firefox.rb +32 -0
  11. data/lib/ferrum/browser/options.rb +43 -3
  12. data/lib/ferrum/browser/process.rb +56 -4
  13. data/lib/ferrum/browser/xvfb.rb +24 -0
  14. data/lib/ferrum/browser.rb +35 -9
  15. data/lib/ferrum/client/subscriber.rb +58 -0
  16. data/lib/ferrum/client/web_socket.rb +63 -12
  17. data/lib/ferrum/client.rb +206 -14
  18. data/lib/ferrum/context.rb +99 -4
  19. data/lib/ferrum/contexts.rb +136 -22
  20. data/lib/ferrum/cookies/cookie.rb +7 -1
  21. data/lib/ferrum/cookies.rb +5 -0
  22. data/lib/ferrum/dialog.rb +18 -2
  23. data/lib/ferrum/downloads.rb +52 -0
  24. data/lib/ferrum/errors.rb +62 -6
  25. data/lib/ferrum/frame/dom.rb +17 -0
  26. data/lib/ferrum/frame/runtime.rb +49 -8
  27. data/lib/ferrum/frame.rb +58 -1
  28. data/lib/ferrum/headers.rb +6 -0
  29. data/lib/ferrum/interceptable.rb +62 -0
  30. data/lib/ferrum/keyboard.rb +25 -0
  31. data/lib/ferrum/mouse.rb +6 -0
  32. data/lib/ferrum/network/auth_request.rb +81 -2
  33. data/lib/ferrum/network/error.rb +15 -0
  34. data/lib/ferrum/network/exchange.rb +10 -0
  35. data/lib/ferrum/network/intercepted_request.rb +94 -2
  36. data/lib/ferrum/network/request.rb +1 -1
  37. data/lib/ferrum/network/response.rb +16 -1
  38. data/lib/ferrum/network.rb +122 -10
  39. data/lib/ferrum/node.rb +305 -15
  40. data/lib/ferrum/page/animation.rb +7 -2
  41. data/lib/ferrum/page/frames.rb +69 -7
  42. data/lib/ferrum/page/screencast.rb +5 -0
  43. data/lib/ferrum/page/screenshot.rb +52 -20
  44. data/lib/ferrum/page/stream.rb +56 -0
  45. data/lib/ferrum/page/tracing.rb +6 -0
  46. data/lib/ferrum/page.rb +141 -46
  47. data/lib/ferrum/proxy.rb +52 -2
  48. data/lib/ferrum/rgba.rb +10 -0
  49. data/lib/ferrum/target.rb +124 -1
  50. data/lib/ferrum/utils/attempt.rb +20 -0
  51. data/lib/ferrum/utils/elapsed_time.rb +38 -0
  52. data/lib/ferrum/utils/event.rb +14 -0
  53. data/lib/ferrum/utils/platform.rb +22 -1
  54. data/lib/ferrum/utils/thread.rb +12 -0
  55. data/lib/ferrum/version.rb +1 -1
  56. data/lib/ferrum/worker.rb +125 -0
  57. data/lib/ferrum.rb +7 -0
  58. metadata +8 -21
@@ -124,7 +124,9 @@ module Ferrum
124
124
  #
125
125
  # @return [String]
126
126
  #
127
- def body
127
+ # @raise [Ferrum::BrowserError]
128
+ #
129
+ def body!
128
130
  @body ||= begin
129
131
  body, encoded = @page.command("Network.getResponseBody", requestId: id)
130
132
  .values_at("body", "base64Encoded")
@@ -132,6 +134,19 @@ module Ferrum
132
134
  end
133
135
  end
134
136
 
137
+ #
138
+ # The response body.
139
+ #
140
+ # @return [String, nil]
141
+ #
142
+ def body
143
+ body!
144
+ rescue Ferrum::BrowserError
145
+ # nop
146
+ end
147
+
148
+ #
149
+ # Whether this response is the main frame's current response.
135
150
  #
136
151
  # @return [Boolean]
137
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
@@ -17,7 +24,7 @@ module Ferrum
17
24
  SignedExchange Ping CSPViolationReport Preflight Other].freeze
18
25
  AUTHORIZE_BLOCK_MISSING = "Block is missing, call `authorize(...) { |r| r.continue } " \
19
26
  "or subscribe to `on(:request)` events before calling it"
20
- AUTHORIZE_TYPE_WRONG = ":type should be in #{AUTHORIZE_TYPE}"
27
+ AUTHORIZE_TYPE_WRONG = ":type should be in #{AUTHORIZE_TYPE}".freeze
21
28
  ALLOWED_CONNECTION_TYPE = %w[none cellular2g cellular3g cellular4g bluetooth ethernet wifi wimax other].freeze
22
29
 
23
30
  # Network traffic.
@@ -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