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
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "ferrum/utils/elapsed_time"
5
+ require "ferrum/utils/platform"
6
+
7
+ module Ferrum
8
+ class Browser
9
+ class Process
10
+ #
11
+ # OS-level process termination and user-data-directory removal, kept
12
+ # separate from the rest of {Process} (spawning, CDP handshake, ...).
13
+ # Both the GC finalizer and the explicit {Process#stop} path need to
14
+ # run the exact same kill/cleanup logic on plain pid/path values --
15
+ # never on `self`. A finalizer proc that closes over the object it's
16
+ # attached to creates a reference cycle GC can never collect, so the
17
+ # finalizer would never run; keeping this logic as free functions
18
+ # taking plain arguments sidesteps that entirely.
19
+ #
20
+ module Killer
21
+ module_function
22
+
23
+ KILL_TIMEOUT = 2
24
+ WAIT_KILLED = 0.05
25
+ REAP_TIMEOUT = 0.5
26
+ REMOVE_DIR_RETRIES = 5
27
+ REMOVE_DIR_RETRY_DELAY = 0.1
28
+
29
+ #
30
+ # Kills the process at the given pid (and its process group, if
31
+ # any), escalating from TERM to KILL if it doesn't exit within
32
+ # {KILL_TIMEOUT} seconds.
33
+ #
34
+ # @param [Integer] pid
35
+ #
36
+ # @return [void]
37
+ #
38
+ def kill(pid)
39
+ if Utils::Platform.windows?
40
+ # Process.kill is unreliable on Windows
41
+ ::Process.kill("KILL", pid) unless system("taskkill /f /t /pid #{pid} >NUL 2>NUL")
42
+ return
43
+ end
44
+
45
+ send_signal(pid, "TERM")
46
+ start = Utils::ElapsedTime.monotonic_time
47
+ leader_exited = false
48
+ loop do
49
+ # The leader (the pid we actually spawned and can #wait on) may
50
+ # exit well before the rest of its process group does -- e.g. a
51
+ # renderer/zygote that ignores TERM. Reaping the leader must not
52
+ # by itself end the loop, or that child is orphaned forever
53
+ # since KILL is only ever sent from the timeout branch below.
54
+ leader_exited ||= !::Process.wait(pid, ::Process::WNOHANG).nil?
55
+ break if leader_exited && !process_group_alive?(pid)
56
+
57
+ sleep(WAIT_KILLED)
58
+ next unless Utils::ElapsedTime.timeout?(start, KILL_TIMEOUT)
59
+
60
+ send_signal(pid, "KILL")
61
+ wait_reaped(pid) unless leader_exited
62
+ break
63
+ end
64
+ rescue Errno::ESRCH, Errno::ECHILD
65
+ # nop
66
+ end
67
+
68
+ #
69
+ # Waits for `pid` to become reapable, giving up after `timeout`.
70
+ #
71
+ # Deliberately a bounded poll rather than a blocking `Process.wait`.
72
+ # {#finalizer} installs {#kill} as an `ObjectSpace` finalizer, so
73
+ # it can run while the interpreter is shutting down, and a blocking
74
+ # wait there never returns.
75
+ #
76
+ # @param [Integer] pid
77
+ # @param [Float] timeout
78
+ #
79
+ # @return [Integer, nil]
80
+ # The reaped pid, or `nil` if it didn't exit in time or wasn't ours.
81
+ #
82
+ def wait_reaped(pid, timeout: REAP_TIMEOUT)
83
+ start = Utils::ElapsedTime.monotonic_time
84
+ loop do
85
+ reaped = ::Process.wait(pid, ::Process::WNOHANG)
86
+ return reaped if reaped
87
+ return nil if Utils::ElapsedTime.timeout?(start, timeout)
88
+
89
+ sleep(WAIT_KILLED)
90
+ end
91
+ rescue Errno::ECHILD, Errno::ESRCH
92
+ nil
93
+ end
94
+
95
+ #
96
+ # Builds the proc registered as a browser's `ObjectSpace` finalizer,
97
+ # cleaning up after a browser nobody quit.
98
+ #
99
+ # Kills every pid first and removes the directory afterwards. That
100
+ # order is the whole point of this being one finalizer instead of
101
+ # two: Ruby runs an object's finalizers in registration order, and
102
+ # removing the user data directory while the browser is still alive
103
+ # means Chrome recreates it on the way out, leaking it for good.
104
+ #
105
+ # @param [Array<Integer>, Integer, nil] pids
106
+ # Process ids to kill, e.g. the browser and Xvfb.
107
+ # @param [String, nil] path
108
+ # User data directory to remove once they are dead.
109
+ #
110
+ # @return [Proc]
111
+ #
112
+ def finalizer(pids, path)
113
+ pids = Array(pids).compact
114
+ proc do
115
+ pids.each { |pid| kill(pid) }
116
+ remove_directory(path) if path
117
+ end
118
+ end
119
+
120
+ #
121
+ # Signals the whole process group Chrome was spawned into (it's
122
+ # spawned with `pgroup: true`), so its child processes (renderer,
123
+ # GPU, zygote, ...) are cleaned up too instead of being left
124
+ # orphaned. Falls back to signaling just the pid directly if
125
+ # there's no such process group to signal (e.g. Xvfb, which isn't
126
+ # spawned with `pgroup: true`) or the group can't be signaled.
127
+ #
128
+ # @param [Integer] pid
129
+ # @param [String] name
130
+ #
131
+ # @return [void]
132
+ #
133
+ def send_signal(pid, name)
134
+ ::Process.kill(name, -pid)
135
+ rescue Errno::EPERM, Errno::ESRCH
136
+ ::Process.kill(name, pid)
137
+ end
138
+
139
+ #
140
+ # Checks whether any process in pid's process group is still alive.
141
+ #
142
+ # @param [Integer] pid
143
+ #
144
+ # @return [Boolean]
145
+ #
146
+ def process_group_alive?(pid)
147
+ ::Process.kill(0, -pid)
148
+ true
149
+ rescue Errno::ESRCH
150
+ false
151
+ rescue Errno::EPERM
152
+ true
153
+ end
154
+
155
+ #
156
+ # Removes the given directory, retrying with exponential backoff on
157
+ # transient errors. Chrome can briefly hold file locks right after
158
+ # being killed, so the directory may not be removable on the first
159
+ # try; retrying avoids leaking temp directories in that case.
160
+ #
161
+ # @param [String] path
162
+ # Directory to remove.
163
+ # @param [Integer] retries
164
+ # Maximum number of removal attempts.
165
+ # @param [Float] delay
166
+ # Base delay, in seconds, before the first retry; doubles on each
167
+ # subsequent attempt.
168
+ #
169
+ # @return [void]
170
+ #
171
+ def remove_directory(path, retries: REMOVE_DIR_RETRIES, delay: REMOVE_DIR_RETRY_DELAY)
172
+ retries.times do |attempt|
173
+ FileUtils.remove_entry(path)
174
+ break
175
+ rescue Errno::ENOENT
176
+ break
177
+ rescue Errno::ENOTEMPTY, Errno::EBUSY, Errno::EACCES, Errno::EPERM => e
178
+ raise e if attempt == retries - 1
179
+
180
+ sleep(delay * (2**attempt))
181
+ end
182
+ rescue StandardError => e
183
+ warn("[Ferrum] Failed to remove user data dir #{path}: #{e.class}: #{e.message}")
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -7,69 +7,58 @@ require "tmpdir"
7
7
  require "forwardable"
8
8
  require "ferrum/browser/options/base"
9
9
  require "ferrum/browser/options/chrome"
10
- require "ferrum/browser/options/firefox"
10
+ require "ferrum/browser/process/killer"
11
11
  require "ferrum/browser/command"
12
12
  require "ferrum/utils/elapsed_time"
13
13
  require "ferrum/utils/platform"
14
+ require "ferrum/utils/thread"
14
15
 
15
16
  module Ferrum
16
17
  class Browser
18
+ #
19
+ # Spawns and manages the lifecycle of the browser OS process: builds the
20
+ # launch {Command}, starts it (optionally under {Xvfb} for headful mode),
21
+ # waits for the CDP WebSocket endpoint to become available, and handles
22
+ # stopping/restarting the process and cleaning up its user data directory.
23
+ #
17
24
  class Process
18
- KILL_TIMEOUT = 2
19
- WAIT_KILLED = 0.05
20
-
21
25
  extend Forwardable
22
26
 
27
+ HTTP_SCHEMES = { "ws" => "http", "wss" => "https" }.freeze
28
+ WS_SCHEMES = { "http" => "ws", "https" => "wss" }.freeze
29
+ LOCAL_HOSTS = %w[0.0.0.0 :: 127.0.0.1 ::1 localhost].freeze
30
+
23
31
  delegate path: :command
24
32
 
33
+ #
34
+ # Builds and starts a new browser process.
35
+ #
36
+ # @param [Array] args
37
+ # Arguments forwarded to {#initialize}.
38
+ #
39
+ # @return [Process]
40
+ #
25
41
  def self.start(*args)
26
42
  new(*args).tap(&:start)
27
43
  end
28
44
 
29
- def self.process_killer(pid)
30
- proc do
31
- if Utils::Platform.windows?
32
- # Process.kill is unreliable on Windows
33
- ::Process.kill("KILL", pid) unless system("taskkill /f /t /pid #{pid} >NUL 2>NUL")
34
- else
35
- ::Process.kill("USR1", pid)
36
- start = Utils::ElapsedTime.monotonic_time
37
- while ::Process.wait(pid, ::Process::WNOHANG).nil?
38
- sleep(WAIT_KILLED)
39
- next unless Utils::ElapsedTime.timeout?(start, KILL_TIMEOUT)
40
-
41
- ::Process.kill("KILL", pid)
42
- ::Process.wait(pid)
43
- break
44
- end
45
- end
46
- rescue Errno::ESRCH, Errno::ECHILD
47
- # nop
48
- end
49
- end
50
-
51
- def self.directory_remover(path)
52
- proc {
53
- begin
54
- FileUtils.remove_entry(path)
55
- rescue StandardError
56
- Errno::ENOENT
57
- end
58
- }
59
- end
60
-
61
45
  attr_reader :host, :port, :ws_url, :pid, :command,
62
46
  :default_user_agent, :browser_version, :protocol_version,
63
47
  :v8_version, :webkit_version, :xvfb
64
48
 
65
49
  def initialize(options)
66
50
  @pid = @xvfb = @user_data_dir = nil
51
+ @requested_host = options.host
67
52
 
68
53
  if options.ws_url || options.url
69
54
  # `:ws_url` option is higher priority than `:url`, parse versions
70
55
  # and use it as a ws_url, otherwise use what has been parsed.
71
- response = parse_json_version(options.ws_url || options.url)
72
- self.ws_url = options.ws_url || response&.[]("webSocketDebuggerUrl")
56
+ endpoint = options.ws_url || options.url
57
+ response = parse_json_version(endpoint)
58
+ ws_url = options.ws_url || reachable_ws_url(response&.[]("webSocketDebuggerUrl"), endpoint)
59
+ raise NoWebSocketUrlError, endpoint unless ws_url
60
+
61
+ self.ws_url = ws_url
73
62
  return
74
63
  end
75
64
 
@@ -77,12 +66,15 @@ module Ferrum
77
66
  @process_timeout = options.process_timeout
78
67
  @env = Hash(options.env)
79
68
 
80
- tmpdir = Dir.mktmpdir("ferrum_user_data_dir_")
81
- ObjectSpace.define_finalizer(self, self.class.directory_remover(tmpdir))
82
- @user_data_dir = tmpdir
83
- @command = Command.build(options, tmpdir)
69
+ @user_data_dir = Dir.mktmpdir("ferrum_user_data_dir_")
70
+ @command = Command.build(options, @user_data_dir)
84
71
  end
85
72
 
73
+ #
74
+ # Spawns the browser process and waits for it to become reachable over CDP.
75
+ #
76
+ # @return [void]
77
+ #
86
78
  def start
87
79
  # Don't do anything as browser is already running as external process.
88
80
  return if ws_url
@@ -93,14 +85,11 @@ module Ferrum
93
85
  process_options[:pgroup] = true unless Utils::Platform.windows?
94
86
  process_options[:out] = process_options[:err] = write_io
95
87
 
96
- if @command.xvfb?
97
- @xvfb = Xvfb.start(@command.options)
98
- ObjectSpace.define_finalizer(self, self.class.process_killer(@xvfb.pid))
99
- end
88
+ @xvfb = Xvfb.start(@command.options) if @command.xvfb?
100
89
 
101
90
  env = Hash(@xvfb&.to_env).merge(@env)
102
91
  @pid = ::Process.spawn(env, *@command.to_a, process_options)
103
- ObjectSpace.define_finalizer(self, self.class.process_killer(@pid))
92
+ ObjectSpace.define_finalizer(self, Killer.finalizer([@pid, @xvfb&.pid], @user_data_dir))
104
93
 
105
94
  parse_ws_url(read_io, @process_timeout)
106
95
  parse_json_version(ws_url)
@@ -109,22 +98,47 @@ module Ferrum
109
98
  end
110
99
  end
111
100
 
112
- def stop
113
- if @pid
114
- kill(@pid)
115
- kill(@xvfb.pid) if @xvfb&.pid
116
- @pid = nil
117
- end
118
-
119
- remove_user_data_dir if @user_data_dir
120
- ObjectSpace.undefine_finalizer(self)
101
+ #
102
+ # Kills the browser process (and Xvfb, if running) and removes the user
103
+ # data directory.
104
+ #
105
+ # @param [Boolean] wait
106
+ # Whether to block until the process is confirmed dead and its user
107
+ # data directory removed (the default), or return immediately and
108
+ # run that cleanup on a background thread instead. Killing a
109
+ # stubborn process group can block for up to {Killer::KILL_TIMEOUT}
110
+ # seconds, plus retries removing its directory, which matters when quitting
111
+ # many browsers in a hot path. `wait: false` is not used by
112
+ # {#restart}, which always waits so the old process is fully gone
113
+ # before the new one starts.
114
+ #
115
+ # @return [Thread, nil]
116
+ # The background cleanup thread when `wait: false`; the caller can
117
+ # `#join` it if they need cleanup to have finished, e.g. before
118
+ # process exit or before reusing a fixed port. `nil` when `wait:
119
+ # true`.
120
+ #
121
+ def stop(wait: true)
122
+ return sync_stop if wait
123
+
124
+ async_stop
121
125
  end
122
126
 
127
+ #
128
+ # Stops and starts the browser process again.
129
+ #
130
+ # @return [void]
131
+ #
123
132
  def restart
124
133
  stop
125
134
  start
126
135
  end
127
136
 
137
+ #
138
+ # Custom inspection that omits noisy internal command details.
139
+ #
140
+ # @return [String]
141
+ #
128
142
  def inspect
129
143
  "#<#{self.class} " \
130
144
  "@user_data_dir=#{@user_data_dir.inspect} " \
@@ -138,12 +152,40 @@ module Ferrum
138
152
 
139
153
  private
140
154
 
141
- def kill(pid)
142
- self.class.process_killer(pid).call
155
+ def sync_stop
156
+ if @pid
157
+ Killer.kill(@pid)
158
+ Killer.kill(@xvfb.pid) if @xvfb&.pid
159
+ @pid = nil
160
+ end
161
+
162
+ remove_user_data_dir if @user_data_dir
163
+ ObjectSpace.undefine_finalizer(self)
164
+ nil
165
+ end
166
+
167
+ #
168
+ # Snapshots what needs killing/removing, clears instance state so the
169
+ # object looks stopped right away, and does the actual work on a
170
+ # background thread. The finalizer is left in place as a backup until
171
+ # that thread finishes, in case the process exits before it does.
172
+ #
173
+ def async_stop
174
+ pid = @pid
175
+ xvfb_pid = @xvfb&.pid
176
+ user_data_dir = @user_data_dir
177
+ @pid = @user_data_dir = nil
178
+
179
+ Utils::Thread.spawn do
180
+ Killer.kill(pid) if pid
181
+ Killer.kill(xvfb_pid) if xvfb_pid
182
+ Killer.remove_directory(user_data_dir) if user_data_dir
183
+ ObjectSpace.undefine_finalizer(self)
184
+ end
143
185
  end
144
186
 
145
187
  def remove_user_data_dir
146
- self.class.directory_remover(@user_data_dir).call
188
+ Killer.remove_directory(@user_data_dir)
147
189
  @user_data_dir = nil
148
190
  end
149
191
 
@@ -159,7 +201,7 @@ module Ferrum
159
201
  read_io.wait_readable(max_time - now)
160
202
  else
161
203
  if output.match(regexp)
162
- self.ws_url = output.match(regexp)[1].strip
204
+ self.ws_url = rewrite_host(output.match(regexp)[1].strip)
163
205
  break
164
206
  end
165
207
  end
@@ -177,6 +219,12 @@ module Ferrum
177
219
  @port = @ws_url.port
178
220
  end
179
221
 
222
+ def rewrite_host(url)
223
+ uri = Addressable::URI.parse(url)
224
+ uri.host = @requested_host
225
+ uri.to_s
226
+ end
227
+
180
228
  def close_io(*ios)
181
229
  ios.each do |io|
182
230
  io.close if io && !io.closed?
@@ -186,18 +234,7 @@ module Ferrum
186
234
  end
187
235
 
188
236
  def parse_json_version(url)
189
- url = URI.join(url, "/json/version")
190
-
191
- if %w[wss ws].include?(url.scheme)
192
- url.scheme = case url.scheme
193
- when "ws"
194
- "http"
195
- when "wss"
196
- "https"
197
- end
198
- end
199
-
200
- response = JSON.parse(::Net::HTTP.get(URI(url.to_s)))
237
+ response = JSON.parse(::Net::HTTP.get(URI(json_version_url(url).to_s)))
201
238
 
202
239
  @v8_version = response["V8-Version"]
203
240
  @browser_version = response["Browser"]
@@ -209,6 +246,25 @@ module Ferrum
209
246
  rescue JSON::ParserError
210
247
  # nop
211
248
  end
249
+
250
+ def json_version_url(url)
251
+ url = Addressable::URI.parse(url).dup
252
+ url.scheme = HTTP_SCHEMES.fetch(url.scheme, url.scheme)
253
+ url.path = "/json/version"
254
+ url
255
+ end
256
+
257
+ def reachable_ws_url(ws_url, endpoint)
258
+ return unless ws_url
259
+
260
+ ws_url = Addressable::URI.parse(ws_url)
261
+ return ws_url.to_s unless LOCAL_HOSTS.include?(ws_url.host)
262
+
263
+ url = Addressable::URI.parse(endpoint).dup
264
+ url.scheme = WS_SCHEMES.fetch(url.scheme, url.scheme)
265
+ url.path = ws_url.path
266
+ url.to_s
267
+ end
212
268
  end
213
269
  end
214
270
  end
@@ -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
- at_css at_xpath css xpath current_url current_title url title
32
+ at_css at_xpath css xpath wait_for_selector 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,26 +81,30 @@ 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
88
104
  # setting off.
89
105
  #
90
- # @option options [:chrome, :firefox] :browser_name (:chrome)
91
- # Sets the browser's name. **Note:** only experimental support for
92
- # `:firefox` for now.
106
+ # @option options [:chrome, :opera, :edge] :browser_name (:chrome)
107
+ # Sets the browser's name.
93
108
  #
94
109
  # @option options [String] :browser_path
95
110
  # Path to Chrome binary, you can also set ENV variable as
@@ -172,7 +187,7 @@ module Ferrum
172
187
  ensure
173
188
  if block_given?
174
189
  page&.close
175
- context.dispose if new_context
190
+ context&.dispose if new_context
176
191
  end
177
192
  end
178
193
 
@@ -211,19 +226,34 @@ module Ferrum
211
226
  contexts.reset
212
227
  end
213
228
 
229
+ #
230
+ # Restarts the browser process, keeping the same options.
231
+ #
214
232
  def restart
215
233
  quit
216
234
  start
217
235
  end
218
236
 
219
- def quit
237
+ #
238
+ # Terminates the browser process and closes the client connection.
239
+ #
240
+ # @param [Boolean] wait
241
+ # Whether to block until the process is confirmed dead and its user
242
+ # data directory removed (the default), or return immediately and run
243
+ # that cleanup on a background thread instead. See {Process#stop}.
244
+ #
245
+ # @return [Thread, nil]
246
+ # The background cleanup thread when `wait: false`, `nil` otherwise.
247
+ #
248
+ def quit(wait: true)
220
249
  return unless @client
221
250
 
222
- contexts.close_connections
251
+ contexts&.close_connections
223
252
 
224
253
  @client.close
225
- @process.stop
254
+ thread = @process.stop(wait: wait)
226
255
  @client = @process = @contexts = nil
256
+ thread
227
257
  end
228
258
 
229
259
  #