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
@@ -4,12 +4,17 @@ require "ferrum/rgba"
4
4
 
5
5
  module Ferrum
6
6
  class Page
7
+ #
8
+ # Captures screenshots, PDFs, and MHTML snapshots of the page, and
9
+ # exposes viewport/document size helpers used to compute capture areas.
10
+ #
7
11
  module Screenshot
8
12
  FULL_WARNING = "Ignoring :selector or :area in #screenshot since full: true was given at %s"
9
13
  AREA_WARNING = "Ignoring :area in #screenshot since selector: was given at %s"
10
14
 
11
15
  DEFAULT_SCREENSHOT_FORMAT = "png"
12
16
  SUPPORTED_SCREENSHOT_FORMAT = %w[png jpeg jpg webp].freeze
17
+ DEFAULT_RENDER_TIMEOUT = 60
13
18
 
14
19
  DEFAULT_PDF_OPTIONS = {
15
20
  landscape: false,
@@ -27,9 +32,9 @@ module Ferrum
27
32
  A1: { width: 23.40, height: 33.10 },
28
33
  A2: { width: 16.54, height: 23.40 },
29
34
  A3: { width: 11.70, height: 16.54 },
30
- A4: { width: 8.27, height: 11.70 },
31
- A5: { width: 5.83, height: 8.27 },
32
- A6: { width: 4.13, height: 5.83 }
35
+ A4: { width: 8.27, height: 11.70 },
36
+ A5: { width: 5.83, height: 8.27 },
37
+ A6: { width: 4.13, height: 5.83 }
33
38
  }.freeze
34
39
 
35
40
  #
@@ -65,6 +70,11 @@ module Ferrum
65
70
  # @option opts [Ferrum::RGBA] :background_color
66
71
  # Sets the background color.
67
72
  #
73
+ # @param [Numeric] timeout
74
+ # How long to wait for the screenshot to be captured. Defaults to
75
+ # {DEFAULT_RENDER_TIMEOUT} since a full-page capture is a known slow
76
+ # outlier among CDP commands.
77
+ #
68
78
  # @example
69
79
  # page.go_to("https://google.com/")
70
80
  #
@@ -83,10 +93,10 @@ module Ferrum
83
93
  # @example Save with specific background color:
84
94
  # page.screenshot(background_color: Ferrum::RGBA.new(0, 0, 0, 0.0))
85
95
  #
86
- def screenshot(**opts)
96
+ def screenshot(timeout: DEFAULT_RENDER_TIMEOUT, **opts)
87
97
  path, encoding = common_options(**opts)
88
98
  options = screenshot_options(path, **opts)
89
- data = capture_screenshot(options, opts[:full], opts[:background_color])
99
+ data = capture_screenshot(options, opts[:full], opts[:background_color], timeout)
90
100
  return data if encoding == :base64
91
101
 
92
102
  bin = Base64.decode64(data)
@@ -124,15 +134,20 @@ module Ferrum
124
134
  # See other [native options](https://chromedevtools.github.io/devtools-protocol/tot/Page#method-printToPDF) you
125
135
  # can pass.
126
136
  #
137
+ # @param [Numeric] timeout
138
+ # How long to wait for the PDF to be generated. Defaults to
139
+ # {DEFAULT_RENDER_TIMEOUT} since large documents are a known slow
140
+ # outlier among CDP commands.
141
+ #
127
142
  # @example
128
143
  # page.go_to("https://google.com/")
129
144
  # # Save to disk as a PDF
130
145
  # page.pdf(path: "google.pdf", paper_width: 1.0, paper_height: 1.0) # => true
131
146
  #
132
- def pdf(**opts)
147
+ def pdf(timeout: DEFAULT_RENDER_TIMEOUT, **opts)
133
148
  path, encoding = common_options(**opts)
134
149
  options = pdf_options(**opts).merge(transferMode: "ReturnAsStream")
135
- handle = command("Page.printToPDF", **options).fetch("stream")
150
+ handle = command("Page.printToPDF", timeout: timeout, **options).fetch("stream")
136
151
  stream_to(path: path, encoding: encoding, handle: handle)
137
152
  end
138
153
 
@@ -153,18 +168,46 @@ module Ferrum
153
168
  save_file(path, data)
154
169
  end
155
170
 
171
+ #
172
+ # Current viewport size.
173
+ #
174
+ # @return [(Integer, Integer)]
175
+ # The width, height of the viewport.
176
+ #
177
+ # @example
178
+ # page.viewport_size # => [1024, 768]
179
+ #
156
180
  def viewport_size
157
181
  evaluate <<~JS
158
182
  [window.innerWidth, window.innerHeight]
159
183
  JS
160
184
  end
161
185
 
186
+ #
187
+ # The ratio of the resolution in physical pixels to the resolution in
188
+ # CSS pixels for the current display device.
189
+ #
190
+ # @return [Float]
191
+ #
192
+ # @example
193
+ # page.device_pixel_ratio # => 1.0
194
+ #
162
195
  def device_pixel_ratio
163
196
  evaluate <<~JS
164
197
  window.devicePixelRatio
165
198
  JS
166
199
  end
167
200
 
201
+ #
202
+ # Full size of the document, including the part that is not visible in
203
+ # the viewport.
204
+ #
205
+ # @return [(Integer, Integer)]
206
+ # The scroll width, scroll height of the document.
207
+ #
208
+ # @example
209
+ # page.document_size # => [1024, 4000]
210
+ #
168
211
  def document_size
169
212
  evaluate <<~JS
170
213
  [document.documentElement.scrollWidth,
@@ -281,23 +324,12 @@ module Ferrum
281
324
  option.to_s.gsub(%r{(?:_|(/))([a-z\d]*)}) { "#{Regexp.last_match(1)}#{Regexp.last_match(2).capitalize}" }.to_sym
282
325
  end
283
326
 
284
- def capture_screenshot(options, full, background_color)
285
- maybe_resize_fullscreen(full) do
286
- with_background_color(background_color) do
287
- command("Page.captureScreenshot", **options)
288
- end
289
- end.fetch("data")
290
- end
291
-
292
- def maybe_resize_fullscreen(full)
293
- if full
294
- width, height = viewport_size.dup
295
- resize(fullscreen: true)
296
- end
327
+ def capture_screenshot(options, full, background_color, timeout)
328
+ options = options.merge(captureBeyondViewport: true) if full
297
329
 
298
- yield
299
- ensure
300
- resize(width: width, height: height) if full
330
+ with_background_color(background_color) do
331
+ command("Page.captureScreenshot", timeout: timeout, **options)
332
+ end.fetch("data")
301
333
  end
302
334
 
303
335
  def with_background_color(color)
@@ -2,9 +2,31 @@
2
2
 
3
3
  module Ferrum
4
4
  class Page
5
+ #
6
+ # Reads a CDP `IO` stream handle (e.g. from `Page.printToPDF` or
7
+ # `Tracing.tracingComplete`) in chunks and writes its contents to a file
8
+ # on disk or accumulates it in memory.
9
+ #
5
10
  module Stream
6
11
  STREAM_CHUNK = 128 * 1024
7
12
 
13
+ #
14
+ # Reads a CDP `IO` stream to a file on disk, or into memory when no
15
+ # path is given.
16
+ #
17
+ # @param [String, nil] path
18
+ # The path to save the stream's contents to. When `nil` the contents
19
+ # are returned in memory instead.
20
+ #
21
+ # @param [Symbol] encoding
22
+ # `:base64` or `:binary`. Only used when `path` is `nil`.
23
+ #
24
+ # @param [String] handle
25
+ # The CDP `IO` stream handle to read from.
26
+ #
27
+ # @return [Boolean, String]
28
+ # `true` when saved to disk, otherwise the stream's contents.
29
+ #
8
30
  def stream_to(path:, encoding:, handle:)
9
31
  if path.nil?
10
32
  stream_to_memory(encoding: encoding, handle: handle)
@@ -13,17 +35,52 @@ module Ferrum
13
35
  end
14
36
  end
15
37
 
38
+ #
39
+ # Reads a CDP `IO` stream and writes its contents to a file on disk.
40
+ #
41
+ # @param [String] path
42
+ # The path to save the stream's contents to.
43
+ #
44
+ # @param [String] handle
45
+ # The CDP `IO` stream handle to read from.
46
+ #
47
+ # @return [Boolean]
48
+ #
16
49
  def stream_to_file(path:, handle:)
17
50
  File.open(path, "wb") { |f| stream(output: f, handle: handle) }
18
51
  true
19
52
  end
20
53
 
54
+ #
55
+ # Reads a CDP `IO` stream into memory.
56
+ #
57
+ # @param [Symbol] encoding
58
+ # `:base64` to Base64-encode the result, `:binary` to return it as is.
59
+ #
60
+ # @param [String] handle
61
+ # The CDP `IO` stream handle to read from.
62
+ #
63
+ # @return [String]
64
+ #
21
65
  def stream_to_memory(encoding:, handle:)
22
66
  data = String.new # Mutable string has << and compatible to File
23
67
  stream(output: data, handle: handle)
24
68
  encoding == :base64 ? Base64.encode64(data) : data
25
69
  end
26
70
 
71
+ #
72
+ # Reads a CDP `IO` stream in chunks, writing each chunk to the given
73
+ # output until the stream is exhausted, then releases the stream
74
+ # handle on the browser side.
75
+ #
76
+ # @param [#<<] output
77
+ # Anything that responds to `#<<`, e.g. an open `File` or a `String`.
78
+ #
79
+ # @param [String] handle
80
+ # The CDP `IO` stream handle to read from.
81
+ #
82
+ # @return [void]
83
+ #
27
84
  def stream(output:, handle:)
28
85
  loop do
29
86
  result = command("IO.read", handle: handle, size: STREAM_CHUNK)
@@ -32,6 +89,20 @@ module Ferrum
32
89
  output << chunk
33
90
  break if result["eof"]
34
91
  end
92
+ close_stream(handle: handle)
93
+ end
94
+
95
+ #
96
+ # Releases a CDP `IO` stream handle, discarding any backing storage
97
+ # the browser is holding for it.
98
+ #
99
+ # @param [String] handle
100
+ # The CDP `IO` stream handle to close.
101
+ #
102
+ # @return [void]
103
+ #
104
+ def close_stream(handle:)
105
+ command("IO.close", handle: handle)
35
106
  end
36
107
  end
37
108
  end
@@ -2,6 +2,12 @@
2
2
 
3
3
  module Ferrum
4
4
  class Page
5
+ #
6
+ # Records a Chrome performance trace for the page via the CDP
7
+ # [Tracing](https://chromedevtools.github.io/devtools-protocol/tot/Tracing/)
8
+ # domain, optionally including screenshots, and streams the resulting
9
+ # trace data to disk or memory once recording stops.
10
+ #
5
11
  class Tracing
6
12
  EXCLUDED_CATEGORIES = %w[*].freeze
7
13
  SCREENSHOT_CATEGORIES = %w[disabled-by-default-devtools.screenshot].freeze
data/lib/ferrum/page.rb CHANGED
@@ -8,6 +8,8 @@ require "ferrum/headers"
8
8
  require "ferrum/cookies"
9
9
  require "ferrum/dialog"
10
10
  require "ferrum/network"
11
+ require "ferrum/accessibility"
12
+ require "ferrum/interceptable"
11
13
  require "ferrum/downloads"
12
14
  require "ferrum/page/frames"
13
15
  require "ferrum/page/screencast"
@@ -17,12 +19,19 @@ require "ferrum/page/tracing"
17
19
  require "ferrum/page/stream"
18
20
 
19
21
  module Ferrum
22
+ #
23
+ # Represents a single browser tab (a CDP target of type `page`). Owns the
24
+ # tab's {Mouse}, {Keyboard}, {Network}, {Cookies}, {Headers}, {Downloads}
25
+ # and {Accessibility} helpers, as well as its frame tree (see the included
26
+ # {Page::Frames} module), and is the object that navigation, DOM search and
27
+ # JavaScript evaluation methods are ultimately delegated to from {Browser}.
28
+ #
20
29
  class Page
21
30
  GOTO_WAIT = ENV.fetch("FERRUM_GOTO_WAIT", 0.1).to_f
22
31
 
23
32
  extend Forwardable
24
33
 
25
- delegate %i[at_css at_xpath css xpath
34
+ delegate %i[at_css at_xpath css xpath wait_for_selector
26
35
  current_url current_title url title body doctype content=
27
36
  execution_id execution_id! evaluate evaluate_on evaluate_async execute evaluate_func
28
37
  add_script_tag add_style_tag] => :main_frame
@@ -33,6 +42,7 @@ module Ferrum
33
42
  include Screenshot
34
43
  include Frames
35
44
  include Stream
45
+ include Interceptable
36
46
 
37
47
  attr_accessor :referrer
38
48
  attr_reader :context_id, :target_id, :event, :tracing
@@ -57,6 +67,11 @@ module Ferrum
57
67
  # @return [Network]
58
68
  attr_reader :network
59
69
 
70
+ # Accessibility object.
71
+ #
72
+ # @return [Accessibility]
73
+ attr_reader :accessibility
74
+
60
75
  # Headers object.
61
76
  #
62
77
  # @return [Headers]
@@ -88,6 +103,7 @@ module Ferrum
88
103
  @headers = Headers.new(self)
89
104
  @cookies = Cookies.new(self)
90
105
  @network = Network.new(self)
106
+ @accessibility = Accessibility.new(self)
91
107
  @tracing = Tracing.new(self)
92
108
  @downloads = Downloads.new(self)
93
109
 
@@ -118,12 +134,20 @@ module Ferrum
118
134
  rescue TimeoutError
119
135
  if @options.pending_connection_errors
120
136
  pendings = network.traffic.select(&:pending?).map(&:url).compact
121
- raise PendingConnectionsError.new(options[:url], pendings) unless pendings.empty?
137
+ raise PendingConnectionsError.new(options[:url], Array(pendings))
122
138
  end
123
139
  end
124
140
  alias goto go_to
125
141
  alias go go_to
126
142
 
143
+ #
144
+ # Closes the page's target and its underlying client connection.
145
+ #
146
+ # @return [Boolean]
147
+ #
148
+ # @example
149
+ # page.close # => true
150
+ #
127
151
  def close
128
152
  @headers.clear
129
153
  client.command("Target.closeTarget", async: true, targetId: @target_id)
@@ -132,6 +156,11 @@ module Ferrum
132
156
  true
133
157
  end
134
158
 
159
+ #
160
+ # Closes the underlying client connection only, without closing the
161
+ # target itself. Useful when you want to detach from a page without
162
+ # ending the browser tab it represents.
163
+ #
135
164
  def close_connection
136
165
  client&.close
137
166
  end
@@ -147,9 +176,15 @@ module Ferrum
147
176
  #
148
177
  # @param [Float] scale_factor device scale factor value. 0 disables the override
149
178
  #
150
- # @param [Boolean] mobile whether to emulate mobile device
179
+ # @param [Boolean] mobile whether to emulate mobile device and enable touch
151
180
  #
152
- def set_viewport(width:, height:, scale_factor: 0, mobile: false)
181
+ def set_viewport(width: 0, height: 0, scale_factor: 0, mobile: false)
182
+ command(
183
+ "Emulation.setTouchEmulationEnabled",
184
+ enabled: mobile,
185
+ maxTouchPoints: 1
186
+ )
187
+
153
188
  command(
154
189
  "Emulation.setDeviceMetricsOverride",
155
190
  slowmoable: true,
@@ -160,6 +195,26 @@ module Ferrum
160
195
  )
161
196
  end
162
197
 
198
+ #
199
+ # Resizes the window and emulates the viewport accordingly, optionally
200
+ # switching to fullscreen.
201
+ #
202
+ # @param [Integer, nil] width width value in pixels.
203
+ #
204
+ # @param [Integer, nil] height height value in pixels.
205
+ #
206
+ # @param [Boolean] fullscreen whether to put the window into fullscreen
207
+ # mode. When `true`, `width` and `height` are read from
208
+ # {#document_size} instead of the given arguments.
209
+ #
210
+ # @return [Hash{String => Object}]
211
+ #
212
+ # @example
213
+ # page.resize(width: 1024, height: 768)
214
+ #
215
+ # @example
216
+ # page.resize(fullscreen: true)
217
+ #
163
218
  def resize(width: nil, height: nil, fullscreen: false)
164
219
  if fullscreen
165
220
  width, height = document_size
@@ -314,6 +369,15 @@ module Ferrum
314
369
  history_navigate(delta: 1)
315
370
  end
316
371
 
372
+ #
373
+ # Blocks until the page reloads or the timeout is reached.
374
+ #
375
+ # @param [Numeric] timeout
376
+ # Maximum time in seconds to wait for a reload event.
377
+ #
378
+ # @example
379
+ # page.wait_for_reload
380
+ #
317
381
  def wait_for_reload(timeout = 1)
318
382
  @event.reset if @event.set?
319
383
  @event.wait(timeout)
@@ -353,74 +417,102 @@ module Ferrum
353
417
  true
354
418
  end
355
419
 
356
- def command(method, wait: 0, slowmoable: false, **params)
420
+ #
421
+ # Sends a CDP command to the browser and optionally waits for network
422
+ # activity on the main frame to settle before returning.
423
+ #
424
+ # @param [String] method
425
+ # The CDP method name, e.g. `"Page.navigate"`.
426
+ #
427
+ # @param [Numeric] wait
428
+ # How many seconds to wait for a network event on the main frame after
429
+ # the command is sent. `0` disables waiting.
430
+ #
431
+ # @param [Boolean] slowmoable
432
+ # Whether to sleep for `Browser::Options#slowmo` seconds before sending
433
+ # the command.
434
+ #
435
+ # @param [Numeric, nil] timeout
436
+ # Overrides the timeout this command's response is bound by. Defaults
437
+ # to the page's `timeout`. Callers with their own budget (e.g. `#pdf`/
438
+ # `#screenshot`) pass it explicitly.
439
+ #
440
+ # @return [Hash{String => Object}]
441
+ #
442
+ # @example
443
+ # page.command("Page.navigate", url: "https://github.com/")
444
+ #
445
+ def command(method, wait: 0, slowmoable: false, timeout: nil, **params)
357
446
  iteration = @event.reset if wait.positive?
358
447
  sleep(@options.slowmo) if slowmoable && @options.slowmo.positive?
359
- result = client.command(method, **params)
448
+ result = client.command(method, timeout: timeout || self.timeout, **params)
360
449
 
361
450
  if wait.positive?
362
451
  # Wait a bit after command and check if iteration has
363
- # changed which means there was some network event for
364
- # the main frame and it started to load new content.
452
+ # changed, which means there was some network event for
453
+ # the main frame, and it started to load new content.
365
454
  @event.wait(wait)
366
455
  if iteration != @event.iteration
367
- set = @event.wait(timeout)
456
+ set = @event.wait(self.timeout)
368
457
  raise TimeoutError unless set
369
458
  end
370
459
  end
371
460
  result
372
461
  end
373
462
 
463
+ # Subscribes to a CDP event, or to `:dialog`, `:request`, `:response`,
464
+ # `:auth` (the latter three handled by {Interceptable}).
465
+ #
466
+ # @param [Symbol, String] name
467
+ #
468
+ # @return [Integer]
469
+ # The subscription id, used to unsubscribe via {#off}.
374
470
  def on(name, &block)
375
- case name
376
- when :dialog
377
- client.on("Page.javascriptDialogOpening") do |params, index, total|
378
- dialog = Dialog.new(self, params)
379
- block.call(dialog, index, total)
380
- end
381
- when :request
382
- client.on("Fetch.requestPaused") do |params, index, total|
383
- request = Network::InterceptedRequest.new(client, params)
384
- exchange = network.select(request.network_id).last
385
- exchange ||= network.build_exchange(request.network_id)
386
- exchange.intercepted_request = request
387
- block.call(request, index, total)
388
- end
389
- when :auth
390
- client.on("Fetch.authRequired") do |params, index, total|
391
- request = Network::AuthRequest.new(self, params)
392
- block.call(request, index, total)
393
- end
394
- else
395
- client.on(name, &block)
471
+ return super unless name == :dialog
472
+
473
+ client.on("Page.javascriptDialogOpening") do |params, index, total|
474
+ dialog = Dialog.new(self, params)
475
+ block.call(dialog, index, total)
396
476
  end
397
477
  end
398
478
 
479
+ # Unsubscribes a listener previously registered via {#on}.
480
+ #
481
+ # @param [Symbol, String] name
482
+ #
483
+ # @param [Integer] id
484
+ # The subscription id returned by {#on}.
485
+ #
486
+ # @return [void]
399
487
  def off(name, id)
400
- case name
401
- when :dialog
402
- client.off("Page.javascriptDialogOpening", id)
403
- when :request
404
- client.off("Fetch.requestPaused", id)
405
- when :auth
406
- client.off("Fetch.authRequired", id)
407
- else
408
- client.off(name, id)
409
- end
410
- end
488
+ return super unless name == :dialog
411
489
 
412
- def subscribed?(event)
413
- client.subscribed?(event)
490
+ client.off("Page.javascriptDialogOpening", id)
414
491
  end
415
492
 
493
+ # Whether the page is configured to use a proxy.
494
+ #
495
+ # @return [Boolean]
416
496
  def use_proxy?
417
497
  @proxy_host && @proxy_port
418
498
  end
419
499
 
500
+ # Whether the page is configured to use a proxy that requires authentication.
501
+ #
502
+ # @return [Boolean]
420
503
  def use_authorized_proxy?
421
504
  use_proxy? && @proxy_user && @proxy_password
422
505
  end
423
506
 
507
+ #
508
+ # Returns the node id of the document's root element.
509
+ #
510
+ # @param [Boolean] async
511
+ # Whether to send the command without waiting for a response.
512
+ #
513
+ # @return [Integer, Boolean]
514
+ # The root node id, or `true` when sent asynchronously.
515
+ #
424
516
  def document_node_id(async: false)
425
517
  return client.command("DOM.getDocument", async: true, depth: 0) if async
426
518
 
@@ -436,7 +528,7 @@ module Ferrum
436
528
 
437
529
  if @options.logger
438
530
  on("Runtime.consoleAPICalled") do |params|
439
- params["args"].each { |r| @options.logger.puts(r["value"]) }
531
+ log_console_api(params)
440
532
  end
441
533
  end
442
534
 
@@ -458,8 +550,9 @@ module Ferrum
458
550
 
459
551
  def prepare_page
460
552
  command("Page.enable")
553
+ command("Page.setLifecycleEventsEnabled", enabled: true)
461
554
  command("Runtime.enable")
462
- command("DOM.enable")
555
+ command("DOM.enable", includeWhitespace: "all")
463
556
  command("CSS.enable")
464
557
  command("Log.enable")
465
558
  command("Network.enable")
@@ -492,6 +585,16 @@ module Ferrum
492
585
  document_node_id
493
586
  end
494
587
 
588
+ def log_console_api(params)
589
+ message = params.fetch("args", []).filter_map { |arg| arg["value"] || arg["description"] }.join(" ")
590
+ @options.logger.puts("[#{params['type']}] #{message}")
591
+
592
+ params.dig("stackTrace", "callFrames")&.each do |frame|
593
+ location = "#{frame['url']}:#{frame['lineNumber'].to_i + 1}:#{frame['columnNumber'].to_i + 1}"
594
+ @options.logger.puts(" at #{frame['functionName']} (#{location})")
595
+ end
596
+ end
597
+
495
598
  def inject_extensions
496
599
  @options.extensions.each do |extension|
497
600
  # https://github.com/GoogleChrome/puppeteer/issues/1443
data/lib/ferrum/proxy.rb CHANGED
@@ -1,11 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "tempfile"
4
- require "webrick"
5
- require "webrick/httpproxy"
4
+
5
+ begin
6
+ require "webrick"
7
+ require "webrick/httpproxy"
8
+ rescue LoadError
9
+ warn("Please add webrick to your Gemfile to use Ferrum proxy")
10
+ raise
11
+ end
6
12
 
7
13
  module Ferrum
14
+ #
15
+ # A local WEBrick-based proxy server, useful for injecting HTTP Basic-Auth
16
+ # credentials into requests or forwarding to (and rotating between)
17
+ # upstream proxies, cases the browser's own proxy support can't handle
18
+ # directly.
19
+ #
20
+ # @note Requires the `webrick` gem, which isn't a hard dependency of
21
+ # Ferrum; add it to your Gemfile to use this class.
22
+ #
8
23
  class Proxy
24
+ #
25
+ # Builds a new proxy server and starts it.
26
+ #
27
+ # @param [Hash] args
28
+ # Keyword arguments forwarded to {#initialize}.
29
+ #
30
+ # @return [Proxy]
31
+ #
9
32
  def self.start(**args)
10
33
  new(**args).tap(&:start)
11
34
  end
@@ -20,6 +43,11 @@ module Ferrum
20
43
  @password = password
21
44
  end
22
45
 
46
+ #
47
+ # Starts the WEBrick proxy server.
48
+ #
49
+ # @return [void]
50
+ #
23
51
  def start
24
52
  options = {
25
53
  ProxyURI: nil, ServerType: Thread,
@@ -45,12 +73,34 @@ module Ferrum
45
73
  @port = @server.config[:Port]
46
74
  end
47
75
 
76
+ #
77
+ # Changes the upstream proxy the server forwards connections to.
78
+ #
79
+ # @param [String] host
80
+ # Address of the upstream proxy.
81
+ #
82
+ # @param [Integer] port
83
+ # Port of the upstream proxy.
84
+ #
85
+ # @param [String, nil] user
86
+ # Username for upstream proxy authentication.
87
+ #
88
+ # @param [String, nil] password
89
+ # Password for upstream proxy authentication.
90
+ #
91
+ # @return [void]
92
+ #
48
93
  def rotate(host:, port:, user: nil, password: nil)
49
94
  credentials = "#{user}:#{password}@" if user && password
50
95
  proxy_uri = "schema://#{credentials}#{host}:#{port}"
51
96
  @server.config[:ProxyURI] = URI.parse(proxy_uri)
52
97
  end
53
98
 
99
+ #
100
+ # Stops the proxy server and removes the htpasswd file.
101
+ #
102
+ # @return [void]
103
+ #
54
104
  def stop
55
105
  @file&.close(true)
56
106
  @server.shutdown
data/lib/ferrum/rgba.rb CHANGED
@@ -1,6 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ferrum
4
+ #
5
+ # Represents an RGBA color, validating that the red/green/blue components
6
+ # are integers between 0 and 255 and that alpha is a float between 0.0 and
7
+ # 1.0. Used e.g. as the background color option for screenshots.
8
+ #
4
9
  class RGBA
5
10
  def initialize(red, green, blue, alpha)
6
11
  self.red = red
@@ -11,6 +16,11 @@ module Ferrum
11
16
  validate
12
17
  end
13
18
 
19
+ #
20
+ # Converts the color to a Hash.
21
+ #
22
+ # @return [Hash{Symbol => Integer, Float}]
23
+ #
14
24
  def to_h
15
25
  { r: red, g: green, b: blue, a: alpha }
16
26
  end