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
@@ -14,6 +14,12 @@ require "ferrum/utils/platform"
14
14
 
15
15
  module Ferrum
16
16
  class Browser
17
+ #
18
+ # Spawns and manages the lifecycle of the browser OS process: builds the
19
+ # launch {Command}, starts it (optionally under {Xvfb} for headful mode),
20
+ # waits for the CDP WebSocket endpoint to become available, and handles
21
+ # stopping/restarting the process and cleaning up its user data directory.
22
+ #
17
23
  class Process
18
24
  KILL_TIMEOUT = 2
19
25
  WAIT_KILLED = 0.05
@@ -22,10 +28,26 @@ module Ferrum
22
28
 
23
29
  delegate path: :command
24
30
 
31
+ #
32
+ # Builds and starts a new browser process.
33
+ #
34
+ # @param [Array] args
35
+ # Arguments forwarded to {#initialize}.
36
+ #
37
+ # @return [Process]
38
+ #
25
39
  def self.start(*args)
26
40
  new(*args).tap(&:start)
27
41
  end
28
42
 
43
+ #
44
+ # Builds a finalizer proc that kills the process with the given pid.
45
+ #
46
+ # @param [Integer] pid
47
+ # Process id to kill.
48
+ #
49
+ # @return [Proc]
50
+ #
29
51
  def self.process_killer(pid)
30
52
  proc do
31
53
  if Utils::Platform.windows?
@@ -48,6 +70,14 @@ module Ferrum
48
70
  end
49
71
  end
50
72
 
73
+ #
74
+ # Builds a finalizer proc that removes the directory at the given path.
75
+ #
76
+ # @param [String] path
77
+ # Directory to remove.
78
+ #
79
+ # @return [Proc]
80
+ #
51
81
  def self.directory_remover(path)
52
82
  proc {
53
83
  begin
@@ -83,6 +113,11 @@ module Ferrum
83
113
  @command = Command.build(options, tmpdir)
84
114
  end
85
115
 
116
+ #
117
+ # Spawns the browser process and waits for it to become reachable over CDP.
118
+ #
119
+ # @return [void]
120
+ #
86
121
  def start
87
122
  # Don't do anything as browser is already running as external process.
88
123
  return if ws_url
@@ -109,6 +144,12 @@ module Ferrum
109
144
  end
110
145
  end
111
146
 
147
+ #
148
+ # Kills the browser process (and Xvfb, if running) and removes the user
149
+ # data directory.
150
+ #
151
+ # @return [void]
152
+ #
112
153
  def stop
113
154
  if @pid
114
155
  kill(@pid)
@@ -120,11 +161,21 @@ module Ferrum
120
161
  ObjectSpace.undefine_finalizer(self)
121
162
  end
122
163
 
164
+ #
165
+ # Stops and starts the browser process again.
166
+ #
167
+ # @return [void]
168
+ #
123
169
  def restart
124
170
  stop
125
171
  start
126
172
  end
127
173
 
174
+ #
175
+ # Custom inspection that omits noisy internal command details.
176
+ #
177
+ # @return [String]
178
+ #
128
179
  def inspect
129
180
  "#<#{self.class} " \
130
181
  "@user_data_dir=#{@user_data_dir.inspect} " \
@@ -2,10 +2,24 @@
2
2
 
3
3
  module Ferrum
4
4
  class Browser
5
+ #
6
+ # Manages an Xvfb virtual display server, letting a "headful" browser run
7
+ # in environments without a real display (e.g. CI). Spawned by {Process}
8
+ # when the `:xvfb` option is set, and exposes the `DISPLAY` environment
9
+ # variable the browser process needs to attach to it.
10
+ #
5
11
  class Xvfb
6
12
  NOT_FOUND = "Could not find an executable for the Xvfb. Try to install " \
7
13
  "it with your package manager"
8
14
 
15
+ #
16
+ # Builds and starts a new Xvfb instance.
17
+ #
18
+ # @param [Array] args
19
+ # Arguments forwarded to {#initialize}.
20
+ #
21
+ # @return [Xvfb]
22
+ #
9
23
  def self.start(*args)
10
24
  new(*args).tap(&:start)
11
25
  end
@@ -20,11 +34,21 @@ module Ferrum
20
34
  @display_id = (Time.now.to_f * 1000).to_i % 100_000_000
21
35
  end
22
36
 
37
+ #
38
+ # Spawns the Xvfb process on the configured display.
39
+ #
40
+ # @return [void]
41
+ #
23
42
  def start
24
43
  @pid = ::Process.spawn("#{@path} :#{display_id} -screen 0 #{screen_size}")
25
44
  ::Process.detach(@pid)
26
45
  end
27
46
 
47
+ #
48
+ # Environment variables needed to point the browser at this Xvfb display.
49
+ #
50
+ # @return [Hash{String => String}]
51
+ #
28
52
  def to_env
29
53
  { "DISPLAY" => ":#{display_id}" }
30
54
  end
@@ -3,7 +3,7 @@
3
3
  require "base64"
4
4
  require "forwardable"
5
5
  require "ferrum/page"
6
- require "ferrum/proxy"
6
+ require "ferrum/worker"
7
7
  require "ferrum/client"
8
8
  require "ferrum/contexts"
9
9
  require "ferrum/browser/xvfb"
@@ -13,15 +13,25 @@ require "ferrum/browser/binary"
13
13
  require "ferrum/browser/version_info"
14
14
 
15
15
  module Ferrum
16
+ #
17
+ # The main entry point of the library. Instantiating it spawns (or connects
18
+ # to) a browser process and opens a CDP connection to it, exposing a single
19
+ # default {Page} that most of the top-level methods (`go_to`, `at_css`,
20
+ # `screenshot`, etc.) are delegated to for convenience.
21
+ #
22
+ # @note A `Browser` can host multiple {Context}s (like incognito profiles)
23
+ # and multiple {Page}s within each of them, reachable through {#contexts}
24
+ # and {#create_page}.
25
+ #
16
26
  class Browser
17
27
  extend Forwardable
18
28
 
19
29
  delegate %i[default_context] => :contexts
20
- delegate %i[targets create_target page pages windows] => :default_context
30
+ delegate %i[targets create_target page pages windows workers service_workers attach_target] => :default_context
21
31
  delegate %i[go_to goto go back forward refresh reload stop wait_for_reload
22
32
  at_css at_xpath css xpath current_url current_title url title
23
33
  body doctype content=
24
- headers cookies network downloads
34
+ headers cookies network accessibility downloads
25
35
  mouse keyboard
26
36
  screenshot pdf mhtml viewport_size device_pixel_ratio
27
37
  start_screencast stop_screencast
@@ -34,7 +44,8 @@ module Ferrum
34
44
 
35
45
  attr_reader :client, :process, :contexts, :options
36
46
 
37
- delegate %i[timeout timeout= base_url base_url= default_user_agent default_user_agent= extensions] => :options
47
+ delegate %i[timeout timeout= protocol_timeout protocol_timeout=
48
+ base_url base_url= default_user_agent default_user_agent= extensions] => :options
38
49
  delegate %i[command] => :client
39
50
 
40
51
  #
@@ -70,18 +81,23 @@ module Ferrum
70
81
  # When present, debug output is written to this object.
71
82
  #
72
83
  # @option options [Integer, Float] :slowmo
73
- # Set a delay in seconds to wait before sending command.
74
- # Useful companion of headless option, so that you have time to see
84
+ # Set a delay in seconds to wait before sending a command.
85
+ # Useful companion of a headless option, so that you have time to see
75
86
  # changes.
76
87
  #
77
88
  # @option options [Numeric] :timeout (5)
78
- # The number of seconds we'll wait for a response when communicating with
79
- # browser.
89
+ # The number of seconds we'll wait for a response when communicating
90
+ # with the browser: navigations, JS evaluation, DOM queries, dispatching input, etc.
91
+ #
92
+ # @option options [Numeric] :protocol_timeout (5)
93
+ # The number of seconds we'll wait for an individual internal CDP
94
+ # bookkeeping call to respond, e.g. `Target.createTarget`,
95
+ # `Target.attachToTarget`. These normally resolve in milliseconds.
80
96
  #
81
97
  # @option options [Boolean] :js_errors
82
98
  # When true, JavaScript errors get re-raised in Ruby.
83
99
  #
84
- # @option options [Boolean] :pending_connection_errors (false)
100
+ # @option options [Boolean] :pending_connection_errors (true)
85
101
  # When main frame is still waiting for slow responses while timeout is
86
102
  # reached {PendingConnectionsError} is raised. It's better to figure out
87
103
  # why you have slow responses and fix or block them rather than turn this
@@ -172,7 +188,7 @@ module Ferrum
172
188
  ensure
173
189
  if block_given?
174
190
  page&.close
175
- context.dispose if new_context
191
+ context&.dispose if new_context
176
192
  end
177
193
  end
178
194
 
@@ -211,11 +227,17 @@ module Ferrum
211
227
  contexts.reset
212
228
  end
213
229
 
230
+ #
231
+ # Restarts the browser process, keeping the same options.
232
+ #
214
233
  def restart
215
234
  quit
216
235
  start
217
236
  end
218
237
 
238
+ #
239
+ # Terminates the browser process and closes the client connection.
240
+ #
219
241
  def quit
220
242
  return unless @client
221
243
 
@@ -2,6 +2,12 @@
2
2
 
3
3
  module Ferrum
4
4
  class Client
5
+ #
6
+ # Dispatches incoming CDP events to registered callbacks. Messages are
7
+ # queued and processed on dedicated threads, with `Fetch.requestPaused`
8
+ # and `Fetch.authRequired` given priority so request interception isn't
9
+ # delayed behind other events.
10
+ #
5
11
  class Subscriber
6
12
  INTERRUPTIONS = %w[Fetch.requestPaused Fetch.authRequired].freeze
7
13
 
@@ -13,6 +19,16 @@ module Ferrum
13
19
  start
14
20
  end
15
21
 
22
+ #
23
+ # Enqueues an incoming CDP message for dispatch to subscribers.
24
+ # `Fetch.requestPaused`/`Fetch.authRequired` messages jump the regular
25
+ # queue so request interception isn't delayed behind other events.
26
+ #
27
+ # @param [Hash] message
28
+ # The raw CDP message, as parsed from the websocket.
29
+ #
30
+ # @return [void]
31
+ #
16
32
  def <<(message)
17
33
  if INTERRUPTIONS.include?(message["method"])
18
34
  @priority.push(message)
@@ -21,26 +37,68 @@ module Ferrum
21
37
  end
22
38
  end
23
39
 
40
+ #
41
+ # Registers a callback for a CDP event.
42
+ #
43
+ # @param [String] event
44
+ # The event key, as built by {SessionClient.event_name}.
45
+ #
46
+ # @return [Integer]
47
+ # The callback's index within the event's callback list, used to
48
+ # unsubscribe via {#off}.
49
+ #
24
50
  def on(event, &block)
25
51
  @on[event] ||= Concurrent::Array.new
26
52
  @on[event] << block
27
53
  @on[event].index(block)
28
54
  end
29
55
 
56
+ #
57
+ # Unregisters a callback for a CDP event.
58
+ #
59
+ # @param [String] event
60
+ # The event key, as built by {SessionClient.event_name}.
61
+ #
62
+ # @param [Integer] id
63
+ # The callback's index, as returned by {#on}.
64
+ #
65
+ # @return [Boolean]
66
+ #
30
67
  def off(event, id)
31
68
  @on[event].delete_at(id)
32
69
  true
33
70
  end
34
71
 
72
+ #
73
+ # Whether there's at least one callback registered for the event.
74
+ #
75
+ # @param [String] event
76
+ # The event key, as built by {SessionClient.event_name}.
77
+ #
78
+ # @return [Boolean]
79
+ #
35
80
  def subscribed?(event)
36
81
  @on.key?(event)
37
82
  end
38
83
 
84
+ #
85
+ # Stops the regular and priority dispatch threads.
86
+ #
87
+ # @return [void]
88
+ #
39
89
  def close
40
90
  @regular_thread&.kill
41
91
  @priority_thread&.kill
42
92
  end
43
93
 
94
+ #
95
+ # Removes all callbacks registered for a given session.
96
+ #
97
+ # @param [String] session_id
98
+ # The session id to match against registered event keys.
99
+ #
100
+ # @return [void]
101
+ #
44
102
  def clear(session_id:)
45
103
  @on.delete_if { |k, _| k.match?(session_id) }
46
104
  end
@@ -6,6 +6,12 @@ require "websocket/driver"
6
6
 
7
7
  module Ferrum
8
8
  class Client
9
+ #
10
+ # Low-level WebSocket connection to the browser's CDP endpoint. Opens
11
+ # the raw TCP/TLS socket, drives the `websocket-driver` handshake and
12
+ # framing, and exposes a queue of parsed incoming messages alongside
13
+ # methods to send commands and close the connection.
14
+ #
9
15
  class WebSocket
10
16
  WEBSOCKET_BUG_SLEEP = 0.05
11
17
  DEFAULT_PORTS = { "ws" => 80, "wss" => 443 }.freeze
@@ -30,7 +36,10 @@ module Ferrum
30
36
  end
31
37
 
32
38
  max_receive_size ||= ::WebSocket::Driver::MAX_LENGTH
33
- @driver = ::WebSocket::Driver.client(self, max_length: max_receive_size)
39
+ @driver = ::WebSocket::Driver.client(self, max_length: max_receive_size)
40
+ # websocket-driver holds no locks and is called from many threads: commands from
41
+ # callers, pong/close replies from the reader. One lock keeps frames from interleaving.
42
+ @driver_mutex = Mutex.new
34
43
  @messages = Queue.new
35
44
 
36
45
  @screenshot_commands = Concurrent::Hash.new if SKIP_LOGGING_SCREENSHOTS
@@ -41,22 +50,28 @@ module Ferrum
41
50
 
42
51
  start
43
52
 
44
- @driver.start
53
+ @driver_mutex.synchronize { @driver.start }
45
54
  end
46
55
 
56
+ #
57
+ # Handles the driver's `:open` event.
58
+ #
59
+ # @return [void]
60
+ #
47
61
  def on_open(_event)
48
62
  # https://github.com/faye/websocket-driver-ruby/issues/46
49
63
  sleep(WEBSOCKET_BUG_SLEEP)
50
64
  end
51
65
 
66
+ #
67
+ # Handles the driver's `:message` event: parses the incoming frame
68
+ # as JSON and pushes it onto {#messages}. Malformed payloads are
69
+ # dropped rather than raised, to avoid crashing the reader thread.
70
+ #
71
+ # @return [void]
72
+ #
52
73
  def on_message(event)
53
74
  data = safely_parse_json(event.data)
54
- # If we couldn't parse JSON data for some reason (parse error or deeply nested object) we
55
- # don't push response to @messages. Worse that could happen we raise timeout error due to command didn't return
56
- # anything or skip the background notification, but at least we don't crash the thread that crashes the main
57
- # thread and the application.
58
- @messages.push(data) if data
59
-
60
75
  output = event.data
61
76
  if SKIP_LOGGING_SCREENSHOTS && @screenshot_commands[data&.dig("id")]
62
77
  @screenshot_commands.delete(data&.dig("id"))
@@ -64,30 +79,66 @@ module Ferrum
64
79
  end
65
80
 
66
81
  @logger&.puts(" ◀ #{Utils::ElapsedTime.elapsed_time} #{output}\n")
82
+
83
+ # If we couldn't parse JSON data for some reason (parse error or deeply nested object) we
84
+ # don't push response to @messages. Worse that could happen we raise timeout error due to command didn't return
85
+ # anything or skip the background notification, but at least we don't crash the thread that crashes the main
86
+ # thread and the application.
87
+ @messages.push(data) if data
67
88
  end
68
89
 
90
+ #
91
+ # Handles the driver's `:close` event: closes the message queue and
92
+ # underlying socket, then kills the reader thread.
93
+ #
94
+ # @return [void]
95
+ #
69
96
  def on_close(_event)
70
97
  @messages.close
71
98
  @sock.close
72
99
  @thread.kill
73
100
  end
74
101
 
102
+ #
103
+ # Serializes a CDP command to JSON and sends it as a websocket text
104
+ # frame.
105
+ #
106
+ # @param [Hash] data
107
+ # The message to send, must include an `:id` key.
108
+ #
109
+ # @return [void]
110
+ #
75
111
  def send_message(data)
76
112
  @screenshot_commands[data[:id]] = true if SKIP_LOGGING_SCREENSHOTS
77
113
 
78
114
  json = data.to_json
79
- @driver.text(json)
115
+ @driver_mutex.synchronize { @driver.text(json) }
80
116
  @logger&.puts("\n\n▶ #{Utils::ElapsedTime.elapsed_time} #{json}")
81
117
  end
82
118
 
119
+ #
120
+ # Writes raw bytes to the underlying socket. Called by
121
+ # `websocket-driver` to emit frames. Closes {#messages} instead of
122
+ # raising if the connection has already been torn down.
123
+ #
124
+ # @param [String] data
125
+ # The raw bytes to write.
126
+ #
127
+ # @return [void]
128
+ #
83
129
  def write(data)
84
130
  @sock.write(data)
85
131
  rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException
86
132
  @messages.close
87
133
  end
88
134
 
135
+ #
136
+ # Closes the websocket connection by sending a close frame.
137
+ #
138
+ # @return [void]
139
+ #
89
140
  def close
90
- @driver.close
141
+ @driver_mutex.synchronize { @driver.close }
91
142
  end
92
143
 
93
144
  private
@@ -98,7 +149,7 @@ module Ferrum
98
149
  data = @sock.readpartial(512)
99
150
  break unless data
100
151
 
101
- @driver.parse(data)
152
+ @driver_mutex.synchronize { @driver.parse(data) }
102
153
  end
103
154
  rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException
104
155
  @messages.close