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
@@ -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,
@@ -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,51 @@ 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.
74
+ #
75
+ # @param [#<<] output
76
+ # Anything that responds to `#<<`, e.g. an open `File` or a `String`.
77
+ #
78
+ # @param [String] handle
79
+ # The CDP `IO` stream handle to read from.
80
+ #
81
+ # @return [void]
82
+ #
27
83
  def stream(output:, handle:)
28
84
  loop do
29
85
  result = command("IO.read", handle: handle, size: STREAM_CHUNK)
@@ -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,10 +19,18 @@ 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
33
+
24
34
  delegate %i[at_css at_xpath css xpath
25
35
  current_url current_title url title body doctype content=
26
36
  execution_id execution_id! evaluate evaluate_on evaluate_async execute evaluate_func
@@ -32,6 +42,7 @@ module Ferrum
32
42
  include Screenshot
33
43
  include Frames
34
44
  include Stream
45
+ include Interceptable
35
46
 
36
47
  attr_accessor :referrer
37
48
  attr_reader :context_id, :target_id, :event, :tracing
@@ -56,6 +67,11 @@ module Ferrum
56
67
  # @return [Network]
57
68
  attr_reader :network
58
69
 
70
+ # Accessibility object.
71
+ #
72
+ # @return [Accessibility]
73
+ attr_reader :accessibility
74
+
59
75
  # Headers object.
60
76
  #
61
77
  # @return [Headers]
@@ -87,6 +103,7 @@ module Ferrum
87
103
  @headers = Headers.new(self)
88
104
  @cookies = Cookies.new(self)
89
105
  @network = Network.new(self)
106
+ @accessibility = Accessibility.new(self)
90
107
  @tracing = Tracing.new(self)
91
108
  @downloads = Downloads.new(self)
92
109
 
@@ -117,12 +134,20 @@ module Ferrum
117
134
  rescue TimeoutError
118
135
  if @options.pending_connection_errors
119
136
  pendings = network.traffic.select(&:pending?).map(&:url).compact
120
- raise PendingConnectionsError.new(options[:url], pendings) unless pendings.empty?
137
+ raise PendingConnectionsError.new(options[:url], Array(pendings))
121
138
  end
122
139
  end
123
140
  alias goto go_to
124
141
  alias go go_to
125
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
+ #
126
151
  def close
127
152
  @headers.clear
128
153
  client.command("Target.closeTarget", async: true, targetId: @target_id)
@@ -131,6 +156,11 @@ module Ferrum
131
156
  true
132
157
  end
133
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
+ #
134
164
  def close_connection
135
165
  client&.close
136
166
  end
@@ -159,6 +189,26 @@ module Ferrum
159
189
  )
160
190
  end
161
191
 
192
+ #
193
+ # Resizes the window and emulates the viewport accordingly, optionally
194
+ # switching to fullscreen.
195
+ #
196
+ # @param [Integer, nil] width width value in pixels.
197
+ #
198
+ # @param [Integer, nil] height height value in pixels.
199
+ #
200
+ # @param [Boolean] fullscreen whether to put the window into fullscreen
201
+ # mode. When `true`, `width` and `height` are read from
202
+ # {#document_size} instead of the given arguments.
203
+ #
204
+ # @return [Hash{String => Object}]
205
+ #
206
+ # @example
207
+ # page.resize(width: 1024, height: 768)
208
+ #
209
+ # @example
210
+ # page.resize(fullscreen: true)
211
+ #
162
212
  def resize(width: nil, height: nil, fullscreen: false)
163
213
  if fullscreen
164
214
  width, height = document_size
@@ -313,6 +363,15 @@ module Ferrum
313
363
  history_navigate(delta: 1)
314
364
  end
315
365
 
366
+ #
367
+ # Blocks until the page reloads or the timeout is reached.
368
+ #
369
+ # @param [Numeric] timeout
370
+ # Maximum time in seconds to wait for a reload event.
371
+ #
372
+ # @example
373
+ # page.wait_for_reload
374
+ #
316
375
  def wait_for_reload(timeout = 1)
317
376
  @event.reset if @event.set?
318
377
  @event.wait(timeout)
@@ -352,74 +411,102 @@ module Ferrum
352
411
  true
353
412
  end
354
413
 
355
- def command(method, wait: 0, slowmoable: false, **params)
414
+ #
415
+ # Sends a CDP command to the browser and optionally waits for network
416
+ # activity on the main frame to settle before returning.
417
+ #
418
+ # @param [String] method
419
+ # The CDP method name, e.g. `"Page.navigate"`.
420
+ #
421
+ # @param [Numeric] wait
422
+ # How many seconds to wait for a network event on the main frame after
423
+ # the command is sent. `0` disables waiting.
424
+ #
425
+ # @param [Boolean] slowmoable
426
+ # Whether to sleep for `Browser::Options#slowmo` seconds before sending
427
+ # the command.
428
+ #
429
+ # @param [Numeric, nil] timeout
430
+ # Overrides the timeout this command's response is bound by. Defaults
431
+ # to the page's `timeout`. Callers with their own budget (e.g. `#pdf`/
432
+ # `#screenshot`) pass it explicitly.
433
+ #
434
+ # @return [Hash{String => Object}]
435
+ #
436
+ # @example
437
+ # page.command("Page.navigate", url: "https://github.com/")
438
+ #
439
+ def command(method, wait: 0, slowmoable: false, timeout: nil, **params)
356
440
  iteration = @event.reset if wait.positive?
357
441
  sleep(@options.slowmo) if slowmoable && @options.slowmo.positive?
358
- result = client.command(method, **params)
442
+ result = client.command(method, timeout: timeout || self.timeout, **params)
359
443
 
360
444
  if wait.positive?
361
445
  # Wait a bit after command and check if iteration has
362
- # changed which means there was some network event for
363
- # the main frame and it started to load new content.
446
+ # changed, which means there was some network event for
447
+ # the main frame, and it started to load new content.
364
448
  @event.wait(wait)
365
449
  if iteration != @event.iteration
366
- set = @event.wait(timeout)
450
+ set = @event.wait(self.timeout)
367
451
  raise TimeoutError unless set
368
452
  end
369
453
  end
370
454
  result
371
455
  end
372
456
 
457
+ # Subscribes to a CDP event, or to `:dialog`, `:request`, `:auth` (the
458
+ # latter two handled by {Interceptable}).
459
+ #
460
+ # @param [Symbol, String] name
461
+ #
462
+ # @return [Integer]
463
+ # The subscription id, used to unsubscribe via {#off}.
373
464
  def on(name, &block)
374
- case name
375
- when :dialog
376
- client.on("Page.javascriptDialogOpening") do |params, index, total|
377
- dialog = Dialog.new(self, params)
378
- block.call(dialog, index, total)
379
- end
380
- when :request
381
- client.on("Fetch.requestPaused") do |params, index, total|
382
- request = Network::InterceptedRequest.new(client, params)
383
- exchange = network.select(request.network_id).last
384
- exchange ||= network.build_exchange(request.network_id)
385
- exchange.intercepted_request = request
386
- block.call(request, index, total)
387
- end
388
- when :auth
389
- client.on("Fetch.authRequired") do |params, index, total|
390
- request = Network::AuthRequest.new(self, params)
391
- block.call(request, index, total)
392
- end
393
- else
394
- client.on(name, &block)
465
+ return super unless name == :dialog
466
+
467
+ client.on("Page.javascriptDialogOpening") do |params, index, total|
468
+ dialog = Dialog.new(self, params)
469
+ block.call(dialog, index, total)
395
470
  end
396
471
  end
397
472
 
473
+ # Unsubscribes a listener previously registered via {#on}.
474
+ #
475
+ # @param [Symbol, String] name
476
+ #
477
+ # @param [Integer] id
478
+ # The subscription id returned by {#on}.
479
+ #
480
+ # @return [void]
398
481
  def off(name, id)
399
- case name
400
- when :dialog
401
- client.off("Page.javascriptDialogOpening", id)
402
- when :request
403
- client.off("Fetch.requestPaused", id)
404
- when :auth
405
- client.off("Fetch.authRequired", id)
406
- else
407
- client.off(name, id)
408
- end
409
- end
482
+ return super unless name == :dialog
410
483
 
411
- def subscribed?(event)
412
- client.subscribed?(event)
484
+ client.off("Page.javascriptDialogOpening", id)
413
485
  end
414
486
 
487
+ # Whether the page is configured to use a proxy.
488
+ #
489
+ # @return [Boolean]
415
490
  def use_proxy?
416
491
  @proxy_host && @proxy_port
417
492
  end
418
493
 
494
+ # Whether the page is configured to use a proxy that requires authentication.
495
+ #
496
+ # @return [Boolean]
419
497
  def use_authorized_proxy?
420
498
  use_proxy? && @proxy_user && @proxy_password
421
499
  end
422
500
 
501
+ #
502
+ # Returns the node id of the document's root element.
503
+ #
504
+ # @param [Boolean] async
505
+ # Whether to send the command without waiting for a response.
506
+ #
507
+ # @return [Integer, Boolean]
508
+ # The root node id, or `true` when sent asynchronously.
509
+ #
423
510
  def document_node_id(async: false)
424
511
  return client.command("DOM.getDocument", async: true, depth: 0) if async
425
512
 
@@ -435,17 +522,14 @@ module Ferrum
435
522
 
436
523
  if @options.logger
437
524
  on("Runtime.consoleAPICalled") do |params|
438
- params["args"].each { |r| @options.logger.puts(r["value"]) }
525
+ log_console_api(params)
439
526
  end
440
527
  end
441
528
 
442
529
  if @options.js_errors
443
530
  on("Runtime.exceptionThrown") do |params|
444
531
  # FIXME: https://jvns.ca/blog/2015/11/27/why-rubys-timeout-is-dangerous-and-thread-dot-raise-is-terrifying/
445
- Thread.main.raise JavaScriptError.new(
446
- params.dig("exceptionDetails", "exception"),
447
- params.dig("exceptionDetails", "stackTrace")
448
- )
532
+ Thread.main.raise JavaScriptError, params["exceptionDetails"]
449
533
  end
450
534
  end
451
535
 
@@ -460,8 +544,9 @@ module Ferrum
460
544
 
461
545
  def prepare_page
462
546
  command("Page.enable")
547
+ command("Page.setLifecycleEventsEnabled", enabled: true)
463
548
  command("Runtime.enable")
464
- command("DOM.enable")
549
+ command("DOM.enable", includeWhitespace: "all")
465
550
  command("CSS.enable")
466
551
  command("Log.enable")
467
552
  command("Network.enable")
@@ -494,6 +579,16 @@ module Ferrum
494
579
  document_node_id
495
580
  end
496
581
 
582
+ def log_console_api(params)
583
+ message = params.fetch("args", []).filter_map { |arg| arg["value"] || arg["description"] }.join(" ")
584
+ @options.logger.puts("[#{params['type']}] #{message}")
585
+
586
+ params.dig("stackTrace", "callFrames")&.each do |frame|
587
+ location = "#{frame['url']}:#{frame['lineNumber'].to_i + 1}:#{frame['columnNumber'].to_i + 1}"
588
+ @options.logger.puts(" at #{frame['functionName']} (#{location})")
589
+ end
590
+ end
591
+
497
592
  def inject_extensions
498
593
  @options.extensions.each do |extension|
499
594
  # 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