ferrum 0.17.2 → 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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  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 +41 -0
  6. data/lib/ferrum/browser/command.rb +20 -0
  7. data/lib/ferrum/browser/options/base.rb +67 -0
  8. data/lib/ferrum/browser/options/chrome.rb +36 -1
  9. data/lib/ferrum/browser/options/firefox.rb +32 -0
  10. data/lib/ferrum/browser/options.rb +41 -2
  11. data/lib/ferrum/browser/process.rb +51 -0
  12. data/lib/ferrum/browser/xvfb.rb +24 -0
  13. data/lib/ferrum/browser.rb +32 -10
  14. data/lib/ferrum/client/subscriber.rb +58 -0
  15. data/lib/ferrum/client/web_socket.rb +62 -11
  16. data/lib/ferrum/client.rb +199 -8
  17. data/lib/ferrum/context.rb +97 -4
  18. data/lib/ferrum/contexts.rb +119 -9
  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 +52 -0
  23. data/lib/ferrum/errors.rb +58 -3
  24. data/lib/ferrum/frame/dom.rb +17 -0
  25. data/lib/ferrum/frame/runtime.rb +48 -7
  26. data/lib/ferrum/frame.rb +58 -1
  27. data/lib/ferrum/headers.rb +6 -0
  28. data/lib/ferrum/interceptable.rb +62 -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 +52 -20
  43. data/lib/ferrum/page/stream.rb +56 -0
  44. data/lib/ferrum/page/tracing.rb +6 -0
  45. data/lib/ferrum/page.rb +139 -42
  46. data/lib/ferrum/proxy.rb +52 -2
  47. data/lib/ferrum/rgba.rb +10 -0
  48. data/lib/ferrum/target.rb +124 -1
  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 +12 -0
  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
@@ -4,6 +4,12 @@ require "ferrum/frame"
4
4
 
5
5
  module Ferrum
6
6
  class Page
7
+ #
8
+ # Tracks the page's frame tree and keeps it in sync with the browser by
9
+ # subscribing to the relevant `Page.*`, `Network.*`, and `Runtime.*` CDP
10
+ # events. Exposes lookups over the tracked {Frame} objects and reports
11
+ # when the tree has settled into an idle state.
12
+ #
7
13
  module Frames
8
14
  # The page's main frame, the top of the tree and the parent of all frames.
9
15
  #
@@ -63,12 +69,16 @@ module Ferrum
63
69
  end
64
70
  end
65
71
 
72
+ private
73
+
66
74
  def frames_subscribe
67
75
  subscribe_frame_attached
68
76
  subscribe_frame_detached
77
+ subscribe_frame_started_navigating
69
78
  subscribe_frame_started_loading
70
79
  subscribe_frame_navigated
71
80
  subscribe_frame_stopped_loading
81
+ subscribe_frame_lifecycle_events
72
82
 
73
83
  subscribe_navigated_within_document
74
84
 
@@ -79,8 +89,6 @@ module Ferrum
79
89
  subscribe_execution_contexts_cleared
80
90
  end
81
91
 
82
- private
83
-
84
92
  def subscribe_frame_attached
85
93
  on("Page.frameAttached") do |params|
86
94
  parent_frame_id, frame_id = params.values_at("parentFrameId", "frameId")
@@ -100,6 +108,18 @@ module Ferrum
100
108
  end
101
109
  end
102
110
 
111
+ # Tracks the +loaderId+ for each navigating frame and clears stale
112
+ # lifecycle events from the previous navigation.
113
+ def subscribe_frame_started_navigating
114
+ on("Page.frameStartedNavigating") do |params|
115
+ frame = @frames[params["frameId"]]
116
+ if frame
117
+ frame.loader_id = params["loaderId"]
118
+ frame.lifecycle_events.clear
119
+ end
120
+ end
121
+ end
122
+
103
123
  def subscribe_frame_started_loading
104
124
  on("Page.frameStartedLoading") do |params|
105
125
  frame = @frames[params["frameId"]]
@@ -138,6 +158,33 @@ module Ferrum
138
158
  end
139
159
  end
140
160
 
161
+ # Appends +Page.lifecycleEvent+ events to {Frame#lifecycle_events}.
162
+ # Events from a superseded navigation (+loaderId+ mismatch) are dropped.
163
+ # Transitions +loading="lazy"+ iframes that Chrome never starts loading
164
+ # to +:stopped_loading+ on +networkIdle+, preventing a {Page#go_to} timeout.
165
+ def subscribe_frame_lifecycle_events
166
+ on("Page.lifecycleEvent") do |params|
167
+ frame = @frames[params["frameId"]]
168
+ next unless frame
169
+
170
+ frame.loader_id = params["loaderId"] unless frame.loader_id
171
+ # Reject stale events from a superseded navigation, iframes are not destroyed by Chrome, instead it creates
172
+ # new ones. Main frame stays with the same id, but new events start to flow in.
173
+ next if frame.loader_id != params["loaderId"]
174
+
175
+ event = params.slice("name", "timestamp")
176
+ frame.lifecycle_events << event
177
+
178
+ # This handles iframes with loading="lazy", those that Chrome attaches but parks outside the viewport.
179
+ # They do not trigger any events except `Page.frameAttached` and lifecycle events like:
180
+ # `init` and `networkIdle`. Without it `go_to` would wait for such iframe until it raises timeout.
181
+ if event["name"] == "networkIdle" && !frame.main?
182
+ frame.state = :stopped_loading
183
+ @event.set if idling?
184
+ end
185
+ end
186
+ end
187
+
141
188
  def subscribe_navigated_within_document
142
189
  on("Page.navigatedWithinDocument") do
143
190
  @event.set if idling?
@@ -177,21 +224,36 @@ module Ferrum
177
224
  execution_id = params["executionContextId"]
178
225
  frame = frame_by(execution_id: execution_id)
179
226
  frame&.execution_id = nil
180
- frame&.state = :stopped_loading
227
+ frame&.state = :canceled
181
228
  end
182
229
  end
183
230
 
231
+ # On full navigations/reloads Chrome fires +Page.frameAttached+ with new
232
+ # frame IDs but skips +Page.frameDetached+ for old ones. Removing stale
233
+ # child frames here prevents them from blocking {#idling?} indefinitely.
234
+ # The main frame is reset in-place; child frames are re-added via
235
+ # +Page.frameAttached+.
184
236
  def subscribe_execution_contexts_cleared
185
237
  on("Runtime.executionContextsCleared") do
186
- @frames.each_value do |f|
187
- f.execution_id = nil
188
- f.state = :stopped_loading
238
+ children = []
239
+
240
+ @frames.each do |frame_id, f|
241
+ if f.main?
242
+ f.execution_id = nil
243
+ f.loader_id = nil
244
+ f.lifecycle_events.clear
245
+ f.state = :canceled
246
+ else
247
+ children << frame_id
248
+ end
189
249
  end
250
+
251
+ children.each { |id| @frames.delete(id) }
190
252
  end
191
253
  end
192
254
 
193
255
  def idling?
194
- @frames.values.all? { |f| f.state == :stopped_loading }
256
+ @frames.values.all?(&:idle?)
195
257
  end
196
258
  end
197
259
  end
@@ -2,6 +2,11 @@
2
2
 
3
3
  module Ferrum
4
4
  class Page
5
+ #
6
+ # Starts and stops a live screencast of the page, streaming a sequence
7
+ # of frame images (a video-like feed) to a given block as the page
8
+ # renders.
9
+ #
5
10
  module Screencast
6
11
  # Starts sending frames to record screencast to the given block.
7
12
  #
@@ -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,6 +19,13 @@ 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
 
@@ -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
@@ -160,6 +189,26 @@ module Ferrum
160
189
  )
161
190
  end
162
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
+ #
163
212
  def resize(width: nil, height: nil, fullscreen: false)
164
213
  if fullscreen
165
214
  width, height = document_size
@@ -314,6 +363,15 @@ module Ferrum
314
363
  history_navigate(delta: 1)
315
364
  end
316
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
+ #
317
375
  def wait_for_reload(timeout = 1)
318
376
  @event.reset if @event.set?
319
377
  @event.wait(timeout)
@@ -353,74 +411,102 @@ module Ferrum
353
411
  true
354
412
  end
355
413
 
356
- 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)
357
440
  iteration = @event.reset if wait.positive?
358
441
  sleep(@options.slowmo) if slowmoable && @options.slowmo.positive?
359
- result = client.command(method, **params)
442
+ result = client.command(method, timeout: timeout || self.timeout, **params)
360
443
 
361
444
  if wait.positive?
362
445
  # 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.
446
+ # changed, which means there was some network event for
447
+ # the main frame, and it started to load new content.
365
448
  @event.wait(wait)
366
449
  if iteration != @event.iteration
367
- set = @event.wait(timeout)
450
+ set = @event.wait(self.timeout)
368
451
  raise TimeoutError unless set
369
452
  end
370
453
  end
371
454
  result
372
455
  end
373
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}.
374
464
  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)
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)
396
470
  end
397
471
  end
398
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]
399
481
  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
482
+ return super unless name == :dialog
411
483
 
412
- def subscribed?(event)
413
- client.subscribed?(event)
484
+ client.off("Page.javascriptDialogOpening", id)
414
485
  end
415
486
 
487
+ # Whether the page is configured to use a proxy.
488
+ #
489
+ # @return [Boolean]
416
490
  def use_proxy?
417
491
  @proxy_host && @proxy_port
418
492
  end
419
493
 
494
+ # Whether the page is configured to use a proxy that requires authentication.
495
+ #
496
+ # @return [Boolean]
420
497
  def use_authorized_proxy?
421
498
  use_proxy? && @proxy_user && @proxy_password
422
499
  end
423
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
+ #
424
510
  def document_node_id(async: false)
425
511
  return client.command("DOM.getDocument", async: true, depth: 0) if async
426
512
 
@@ -436,7 +522,7 @@ module Ferrum
436
522
 
437
523
  if @options.logger
438
524
  on("Runtime.consoleAPICalled") do |params|
439
- params["args"].each { |r| @options.logger.puts(r["value"]) }
525
+ log_console_api(params)
440
526
  end
441
527
  end
442
528
 
@@ -458,8 +544,9 @@ module Ferrum
458
544
 
459
545
  def prepare_page
460
546
  command("Page.enable")
547
+ command("Page.setLifecycleEventsEnabled", enabled: true)
461
548
  command("Runtime.enable")
462
- command("DOM.enable")
549
+ command("DOM.enable", includeWhitespace: "all")
463
550
  command("CSS.enable")
464
551
  command("Log.enable")
465
552
  command("Network.enable")
@@ -492,6 +579,16 @@ module Ferrum
492
579
  document_node_id
493
580
  end
494
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
+
495
592
  def inject_extensions
496
593
  @options.extensions.each do |extension|
497
594
  # https://github.com/GoogleChrome/puppeteer/issues/1443