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.
- checksums.yaml +4 -4
- data/README.md +4 -2
- data/lib/ferrum/accessibility/ax_node.rb +108 -0
- data/lib/ferrum/accessibility.rb +106 -0
- data/lib/ferrum/browser/binary.rb +42 -2
- data/lib/ferrum/browser/command.rb +20 -2
- data/lib/ferrum/browser/options/base.rb +66 -0
- data/lib/ferrum/browser/options/chrome.rb +35 -2
- data/lib/ferrum/browser/options.rb +41 -2
- data/lib/ferrum/browser/process/killer.rb +188 -0
- data/lib/ferrum/browser/process.rb +128 -72
- data/lib/ferrum/browser/xvfb.rb +24 -0
- data/lib/ferrum/browser.rb +47 -17
- data/lib/ferrum/client/subscriber.rb +65 -2
- data/lib/ferrum/client/web_socket.rb +126 -13
- data/lib/ferrum/client.rb +225 -12
- data/lib/ferrum/context.rb +113 -7
- data/lib/ferrum/contexts.rb +188 -19
- data/lib/ferrum/cookies/cookie.rb +6 -0
- data/lib/ferrum/cookies.rb +5 -0
- data/lib/ferrum/dialog.rb +18 -2
- data/lib/ferrum/downloads.rb +54 -1
- data/lib/ferrum/errors.rb +103 -4
- data/lib/ferrum/frame/dom.rb +65 -0
- data/lib/ferrum/frame/runtime.rb +57 -15
- data/lib/ferrum/frame.rb +58 -1
- data/lib/ferrum/headers.rb +6 -0
- data/lib/ferrum/interceptable.rb +83 -0
- data/lib/ferrum/keyboard.rb +25 -0
- data/lib/ferrum/mouse.rb +6 -0
- data/lib/ferrum/network/auth_request.rb +81 -2
- data/lib/ferrum/network/error.rb +15 -0
- data/lib/ferrum/network/exchange.rb +10 -0
- data/lib/ferrum/network/intercepted_request.rb +94 -2
- data/lib/ferrum/network/request.rb +1 -1
- data/lib/ferrum/network/response.rb +2 -0
- data/lib/ferrum/network.rb +121 -9
- data/lib/ferrum/node.rb +305 -15
- data/lib/ferrum/page/animation.rb +5 -0
- data/lib/ferrum/page/frames.rb +69 -7
- data/lib/ferrum/page/screencast.rb +5 -0
- data/lib/ferrum/page/screenshot.rb +55 -23
- data/lib/ferrum/page/stream.rb +71 -0
- data/lib/ferrum/page/tracing.rb +6 -0
- data/lib/ferrum/page.rb +148 -45
- data/lib/ferrum/proxy.rb +52 -2
- data/lib/ferrum/rgba.rb +10 -0
- data/lib/ferrum/target.rb +130 -3
- data/lib/ferrum/utils/attempt.rb +20 -0
- data/lib/ferrum/utils/elapsed_time.rb +38 -0
- data/lib/ferrum/utils/event.rb +14 -0
- data/lib/ferrum/utils/platform.rb +21 -0
- data/lib/ferrum/utils/thread.rb +15 -1
- data/lib/ferrum/version.rb +1 -1
- data/lib/ferrum/worker.rb +125 -0
- data/lib/ferrum.rb +7 -0
- metadata +6 -16
- 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/
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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,
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
142
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/ferrum/browser/xvfb.rb
CHANGED
|
@@ -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
|
data/lib/ferrum/browser.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "base64"
|
|
4
4
|
require "forwardable"
|
|
5
5
|
require "ferrum/page"
|
|
6
|
-
require "ferrum/
|
|
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=
|
|
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
|
|
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 (
|
|
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, :
|
|
91
|
-
# Sets the browser's name.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
#
|