ferrum 0.18.0 → 0.19.1
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 +3 -1
- data/lib/ferrum/browser/binary.rb +1 -2
- data/lib/ferrum/browser/command.rb +0 -2
- data/lib/ferrum/browser/options/base.rb +5 -6
- data/lib/ferrum/browser/options/chrome.rb +0 -3
- data/lib/ferrum/browser/process/killer.rb +188 -0
- data/lib/ferrum/browser/process.rb +93 -88
- data/lib/ferrum/browser.rb +15 -7
- data/lib/ferrum/client/subscriber.rb +7 -2
- data/lib/ferrum/client/web_socket.rb +70 -9
- data/lib/ferrum/client.rb +30 -7
- data/lib/ferrum/context.rb +18 -5
- data/lib/ferrum/contexts.rb +75 -16
- data/lib/ferrum/downloads.rb +2 -1
- data/lib/ferrum/errors.rb +46 -2
- data/lib/ferrum/frame/dom.rb +50 -1
- data/lib/ferrum/frame/runtime.rb +9 -8
- data/lib/ferrum/interceptable.rb +25 -4
- data/lib/ferrum/mouse.rb +6 -0
- data/lib/ferrum/page/screenshot.rb +3 -3
- data/lib/ferrum/page/stream.rb +16 -1
- data/lib/ferrum/page.rb +11 -5
- data/lib/ferrum/target.rb +7 -3
- data/lib/ferrum/utils/thread.rb +3 -1
- data/lib/ferrum/version.rb +1 -1
- data/lib/ferrum.rb +1 -1
- metadata +3 -3
- data/lib/ferrum/browser/options/firefox.rb +0 -70
|
@@ -16,6 +16,7 @@ module Ferrum
|
|
|
16
16
|
WEBSOCKET_BUG_SLEEP = 0.05
|
|
17
17
|
DEFAULT_PORTS = { "ws" => 80, "wss" => 443 }.freeze
|
|
18
18
|
SKIP_LOGGING_SCREENSHOTS = !ENV["FERRUM_LOGGING_SCREENSHOTS"]
|
|
19
|
+
ESCAPE_OR_LONE_SURROGATE = /\\u[dD][89abAB]\h{2}\\u[dD][c-fC-F]\h{2}|\\u([dD][89a-fA-F]\h{2})|\\./
|
|
19
20
|
|
|
20
21
|
attr_reader :url, :messages
|
|
21
22
|
|
|
@@ -106,13 +107,26 @@ module Ferrum
|
|
|
106
107
|
# @param [Hash] data
|
|
107
108
|
# The message to send, must include an `:id` key.
|
|
108
109
|
#
|
|
110
|
+
# @param [Numeric, nil] timeout
|
|
111
|
+
# How long the browser may go without accepting any of the frame's
|
|
112
|
+
# bytes. `nil` waits for as long as it takes.
|
|
113
|
+
#
|
|
109
114
|
# @return [void]
|
|
110
115
|
#
|
|
111
|
-
|
|
116
|
+
# @raise [Ferrum::DeadBrowserError]
|
|
117
|
+
# If the browser accepted no data for `timeout` seconds. The connection
|
|
118
|
+
# is closed, since the frame was cut off midway.
|
|
119
|
+
#
|
|
120
|
+
def send_message(data, timeout = nil)
|
|
112
121
|
@screenshot_commands[data[:id]] = true if SKIP_LOGGING_SCREENSHOTS
|
|
113
122
|
|
|
114
123
|
json = data.to_json
|
|
115
|
-
@driver_mutex.synchronize
|
|
124
|
+
@driver_mutex.synchronize do
|
|
125
|
+
@write_timeout = timeout
|
|
126
|
+
@driver.text(json)
|
|
127
|
+
ensure
|
|
128
|
+
@write_timeout = nil
|
|
129
|
+
end
|
|
116
130
|
@logger&.puts("\n\n▶ #{Utils::ElapsedTime.elapsed_time} #{json}")
|
|
117
131
|
end
|
|
118
132
|
|
|
@@ -121,13 +135,28 @@ module Ferrum
|
|
|
121
135
|
# `websocket-driver` to emit frames. Closes {#messages} instead of
|
|
122
136
|
# raising if the connection has already been torn down.
|
|
123
137
|
#
|
|
138
|
+
# A browser that stops reading its socket (hung, or stopped) would block
|
|
139
|
+
# a plain write forever, holding the lock every other command needs to be
|
|
140
|
+
# sent. So the write gives up once the browser accepts no data for the
|
|
141
|
+
# timeout of the command being sent.
|
|
142
|
+
#
|
|
124
143
|
# @param [String] data
|
|
125
144
|
# The raw bytes to write.
|
|
126
145
|
#
|
|
127
146
|
# @return [void]
|
|
128
147
|
#
|
|
148
|
+
# @raise [Ferrum::DeadBrowserError]
|
|
149
|
+
# If the browser accepted no data for the command's timeout.
|
|
150
|
+
#
|
|
129
151
|
def write(data)
|
|
130
|
-
|
|
152
|
+
until data.empty?
|
|
153
|
+
written = @sock.write_nonblock(data, exception: false)
|
|
154
|
+
if written.is_a?(Integer)
|
|
155
|
+
data = data.byteslice(written..)
|
|
156
|
+
elsif !wait_for_socket(written)
|
|
157
|
+
abandon_stalled_write
|
|
158
|
+
end
|
|
159
|
+
end
|
|
131
160
|
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException
|
|
132
161
|
@messages.close
|
|
133
162
|
end
|
|
@@ -141,8 +170,38 @@ module Ferrum
|
|
|
141
170
|
@driver_mutex.synchronize { @driver.close }
|
|
142
171
|
end
|
|
143
172
|
|
|
173
|
+
#
|
|
174
|
+
# Closes the underlying socket whether or not the close handshake ever
|
|
175
|
+
# completed.
|
|
176
|
+
#
|
|
177
|
+
# {#close} only sends a close frame; the socket itself is closed by
|
|
178
|
+
# {#on_close}, which runs when the browser answers with a close frame of
|
|
179
|
+
# its own. A browser that is already dead never answers, so without this
|
|
180
|
+
# the socket stays open until GC finalizes it -- long enough to exhaust
|
|
181
|
+
# the process's file descriptors when browsers are restarted in a loop.
|
|
182
|
+
#
|
|
183
|
+
# @return [void]
|
|
184
|
+
#
|
|
185
|
+
def force_close
|
|
186
|
+
@sock.close unless @sock.closed?
|
|
187
|
+
rescue IOError, SystemCallError
|
|
188
|
+
# nop
|
|
189
|
+
end
|
|
190
|
+
|
|
144
191
|
private
|
|
145
192
|
|
|
193
|
+
def wait_for_socket(state)
|
|
194
|
+
readers, writers = state == :wait_readable ? [[@sock], nil] : [nil, [@sock]]
|
|
195
|
+
IO.select(readers, writers, nil, @write_timeout)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def abandon_stalled_write
|
|
199
|
+
timeout = @write_timeout
|
|
200
|
+
@messages.close
|
|
201
|
+
@sock.close
|
|
202
|
+
raise DeadBrowserError, "Browser accepted no data for #{timeout}s and is likely stuck, connection closed"
|
|
203
|
+
end
|
|
204
|
+
|
|
146
205
|
def start
|
|
147
206
|
@thread = Utils::Thread.spawn do
|
|
148
207
|
loop do
|
|
@@ -152,6 +211,10 @@ module Ferrum
|
|
|
152
211
|
@driver_mutex.synchronize { @driver.parse(data) }
|
|
153
212
|
end
|
|
154
213
|
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException
|
|
214
|
+
# The browser went away, nothing to report.
|
|
215
|
+
rescue StandardError => e
|
|
216
|
+
warn("Ferrum: websocket reader stopped, #{e.class}: #{e.message}\n #{e.backtrace&.first}")
|
|
217
|
+
ensure
|
|
155
218
|
@messages.close
|
|
156
219
|
end
|
|
157
220
|
end
|
|
@@ -161,14 +224,12 @@ module Ferrum
|
|
|
161
224
|
rescue JSON::NestingError
|
|
162
225
|
# nop
|
|
163
226
|
rescue JSON::ParserError
|
|
164
|
-
|
|
227
|
+
safely_parse_lone_surrogates(data)
|
|
165
228
|
end
|
|
166
229
|
|
|
167
|
-
def
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
escaped_data = unescaped_unicode.encode("UTF-8", "UTF-8", undef: :replace, invalid: :replace, replace: "?")
|
|
171
|
-
JSON.parse(escaped_data, max_nesting: false)
|
|
230
|
+
def safely_parse_lone_surrogates(data)
|
|
231
|
+
replaced = data.gsub(ESCAPE_OR_LONE_SURROGATE) { |escape| ::Regexp.last_match(1) ? "\\ufffd" : escape }
|
|
232
|
+
JSON.parse(replaced, max_nesting: false)
|
|
172
233
|
rescue JSON::ParserError
|
|
173
234
|
# nop
|
|
174
235
|
end
|
data/lib/ferrum/client.rb
CHANGED
|
@@ -208,25 +208,33 @@ module Ferrum
|
|
|
208
208
|
# Whether to return immediately instead of waiting for a response.
|
|
209
209
|
#
|
|
210
210
|
# @param [Numeric, nil] timeout
|
|
211
|
-
# How long to wait for the
|
|
211
|
+
# How long to wait for the browser to take the message, and then for its
|
|
212
|
+
# response. Defaults to `protocol_timeout`.
|
|
212
213
|
#
|
|
213
214
|
# @return [Boolean, Hash]
|
|
214
215
|
# `true` when sent asynchronously, otherwise the parsed `"result"`
|
|
215
216
|
# from the response.
|
|
216
217
|
#
|
|
217
218
|
def send_message(message, async:, timeout: nil)
|
|
219
|
+
timeout ||= protocol_timeout
|
|
220
|
+
|
|
218
221
|
if async
|
|
219
|
-
@ws.send_message(message)
|
|
222
|
+
@ws.send_message(message, timeout)
|
|
220
223
|
true
|
|
221
224
|
else
|
|
225
|
+
raise DeadBrowserError if @ws.messages.closed?
|
|
226
|
+
|
|
222
227
|
pending = Concurrent::IVar.new
|
|
223
228
|
@pendings[message[:id]] = pending
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
229
|
+
begin
|
|
230
|
+
@ws.send_message(message, timeout)
|
|
231
|
+
data = pending.value!(timeout)
|
|
232
|
+
ensure
|
|
233
|
+
@pendings.delete(message[:id])
|
|
234
|
+
end
|
|
227
235
|
|
|
228
236
|
raise DeadBrowserError if data.nil? && @ws.messages.closed?
|
|
229
|
-
raise TimeoutError unless data
|
|
237
|
+
raise TimeoutError.new(message[:method], session_id: message[:sessionId]) unless data
|
|
230
238
|
|
|
231
239
|
error, response = data.values_at("error", "result")
|
|
232
240
|
raise_browser_error(error) if error
|
|
@@ -297,6 +305,7 @@ module Ferrum
|
|
|
297
305
|
@pendings.clear
|
|
298
306
|
@thread.kill unless @thread.join(1)
|
|
299
307
|
@subscriber.close
|
|
308
|
+
@ws.force_close
|
|
300
309
|
end
|
|
301
310
|
|
|
302
311
|
#
|
|
@@ -340,9 +349,22 @@ module Ferrum
|
|
|
340
349
|
@pendings[message["id"]]&.set(message)
|
|
341
350
|
end
|
|
342
351
|
end
|
|
352
|
+
rescue StandardError => e
|
|
353
|
+
# Nothing delivers responses once this thread is gone, so the connection
|
|
354
|
+
# is done for. Close it rather than leave every command hanging.
|
|
355
|
+
warn("Ferrum: dispatch stopped, #{e.class}: #{e.message}\n #{e.backtrace&.first}")
|
|
356
|
+
@ws.messages.close
|
|
357
|
+
ensure
|
|
358
|
+
release_pendings
|
|
343
359
|
end
|
|
344
360
|
end
|
|
345
361
|
|
|
362
|
+
# Nothing is going to answer the commands still in flight once the socket
|
|
363
|
+
# is gone, release them instead of making each wait out its timeout.
|
|
364
|
+
def release_pendings
|
|
365
|
+
@pendings.each_value { |pending| pending.try_set(nil) }
|
|
366
|
+
end
|
|
367
|
+
|
|
346
368
|
# Locked so two concurrent commands never share an id and read each
|
|
347
369
|
# other's responses from @pendings.
|
|
348
370
|
def next_command_id
|
|
@@ -354,7 +376,8 @@ module Ferrum
|
|
|
354
376
|
# Node has disappeared while we were trying to get it
|
|
355
377
|
when "No node with given id found",
|
|
356
378
|
"Could not find node with given id",
|
|
357
|
-
"Inspected target navigated or closed"
|
|
379
|
+
"Inspected target navigated or closed",
|
|
380
|
+
"Argument should belong to the same JavaScript world as target object"
|
|
358
381
|
raise NodeNotFoundError, error
|
|
359
382
|
# Context is lost, page is reloading
|
|
360
383
|
when "Cannot find context with specified id"
|
data/lib/ferrum/context.rb
CHANGED
|
@@ -15,14 +15,25 @@ module Ferrum
|
|
|
15
15
|
|
|
16
16
|
attr_reader :id, :targets
|
|
17
17
|
|
|
18
|
-
def initialize(client, contexts, id)
|
|
18
|
+
def initialize(client, contexts, id, implicit: false)
|
|
19
19
|
@id = id
|
|
20
|
+
@implicit = implicit
|
|
20
21
|
@client = client
|
|
21
22
|
@contexts = contexts
|
|
22
23
|
@targets = Concurrent::Map.new
|
|
23
24
|
@pendings = Concurrent::Map.new
|
|
24
25
|
end
|
|
25
26
|
|
|
27
|
+
# Whether this is the browser's implicit context, the one it puts its
|
|
28
|
+
# startup window in. We didn't create it, so we can't dispose it, and
|
|
29
|
+
# Chrome doesn't let us address it by id either: targets are created in
|
|
30
|
+
# it by omitting `browserContextId` altogether.
|
|
31
|
+
#
|
|
32
|
+
# @return [Boolean]
|
|
33
|
+
def implicit?
|
|
34
|
+
@implicit
|
|
35
|
+
end
|
|
36
|
+
|
|
26
37
|
# The context's first known target, creating one via
|
|
27
38
|
# `Target.createTarget` if none has attached yet.
|
|
28
39
|
#
|
|
@@ -92,7 +103,8 @@ module Ferrum
|
|
|
92
103
|
#
|
|
93
104
|
# @raise [NoSuchTargetError]
|
|
94
105
|
def create_target
|
|
95
|
-
|
|
106
|
+
options = { browserContextId: @id } unless implicit?
|
|
107
|
+
target_id = @client.command("Target.createTarget", url: "about:blank", **Hash(options))["targetId"]
|
|
96
108
|
|
|
97
109
|
new_pending = Concurrent::IVar.new
|
|
98
110
|
pending = @pendings.put_if_absent(target_id, new_pending) || new_pending
|
|
@@ -114,7 +126,7 @@ module Ferrum
|
|
|
114
126
|
#
|
|
115
127
|
# @return [Target]
|
|
116
128
|
def add_target(params:, session_id: nil)
|
|
117
|
-
new_target = Target.new(@client, session_id, params)
|
|
129
|
+
new_target = Target.new(@client, session_id, params, implicit: implicit?)
|
|
118
130
|
# `put_if_absent` returns nil if added a new value or existing if there was one already
|
|
119
131
|
target = @targets.put_if_absent(new_target.id, new_target) || new_target
|
|
120
132
|
# on first iteration session_id may be nil, then if session is present here we must set it to the target
|
|
@@ -184,9 +196,10 @@ module Ferrum
|
|
|
184
196
|
end
|
|
185
197
|
end
|
|
186
198
|
|
|
187
|
-
# Disposes this browser context and all of its targets.
|
|
199
|
+
# Disposes this browser context and all of its targets. The browser's
|
|
200
|
+
# implicit context cannot be disposed, see {#implicit?}.
|
|
188
201
|
#
|
|
189
|
-
# @return [Boolean]
|
|
202
|
+
# @return [Boolean, nil]
|
|
190
203
|
def dispose
|
|
191
204
|
@contexts.dispose(@id)
|
|
192
205
|
end
|
data/lib/ferrum/contexts.rb
CHANGED
|
@@ -22,6 +22,7 @@ module Ferrum
|
|
|
22
22
|
@client = client
|
|
23
23
|
@contexts = Concurrent::Map.new
|
|
24
24
|
@manually_attached = Concurrent::Map.new
|
|
25
|
+
@default_context = find_implicit_context
|
|
25
26
|
subscribe
|
|
26
27
|
auto_attach
|
|
27
28
|
discover
|
|
@@ -33,7 +34,9 @@ module Ferrum
|
|
|
33
34
|
@manually_attached[target_id] = true
|
|
34
35
|
end
|
|
35
36
|
|
|
36
|
-
# The browser's
|
|
37
|
+
# The context we work in unless told otherwise: the browser's implicit
|
|
38
|
+
# context when it came up with a startup window (see {#find_implicit_context}),
|
|
39
|
+
# otherwise one of our own, created lazily.
|
|
37
40
|
#
|
|
38
41
|
# @return [Context]
|
|
39
42
|
def default_context
|
|
@@ -84,18 +87,29 @@ module Ferrum
|
|
|
84
87
|
context
|
|
85
88
|
end
|
|
86
89
|
|
|
87
|
-
# Disposes a browser context and all of its targets.
|
|
90
|
+
# Disposes a browser context and all of its targets. The browser's implicit
|
|
91
|
+
# context is not ours to dispose, see {Context#implicit?}.
|
|
92
|
+
#
|
|
93
|
+
# The context is forgotten even if Chrome fails to dispose it in time: its
|
|
94
|
+
# targets are disconnected by then, and asking again only gets
|
|
95
|
+
# `Disposal of browser context ... is already pending` back.
|
|
88
96
|
#
|
|
89
97
|
# @param [String] context_id
|
|
90
98
|
#
|
|
91
|
-
# @return [Boolean]
|
|
99
|
+
# @return [Boolean, nil]
|
|
100
|
+
#
|
|
101
|
+
# @raise [TimeoutError, BrowserError]
|
|
92
102
|
def dispose(context_id)
|
|
93
103
|
context = @contexts[context_id]
|
|
94
104
|
return unless context
|
|
105
|
+
return if context.implicit?
|
|
95
106
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
107
|
+
begin
|
|
108
|
+
context.close_targets_connection
|
|
109
|
+
@client.command("Target.disposeBrowserContext", browserContextId: context.id)
|
|
110
|
+
ensure
|
|
111
|
+
@contexts.delete(context_id)
|
|
112
|
+
end
|
|
99
113
|
true
|
|
100
114
|
end
|
|
101
115
|
|
|
@@ -107,13 +121,23 @@ module Ferrum
|
|
|
107
121
|
@contexts.each_value(&:close_targets_connection)
|
|
108
122
|
end
|
|
109
123
|
|
|
110
|
-
# Disposes every context still known to the browser.
|
|
124
|
+
# Disposes every context still known to the browser. A context that fails
|
|
125
|
+
# to dispose doesn't stop the others from being disposed; the first error
|
|
126
|
+
# is raised once all of them have been tried.
|
|
111
127
|
#
|
|
112
128
|
# @return [void]
|
|
129
|
+
#
|
|
130
|
+
# @raise [TimeoutError, BrowserError]
|
|
113
131
|
def reset
|
|
132
|
+
error = nil
|
|
114
133
|
context_ids = @client.command("Target.getBrowserContexts")["browserContextIds"]
|
|
115
134
|
@default_context = nil if context_ids.include?(@default_context&.id)
|
|
116
|
-
@contexts.
|
|
135
|
+
(@contexts.keys & context_ids).each do |id|
|
|
136
|
+
dispose(id)
|
|
137
|
+
rescue BrowserError, TimeoutError => e
|
|
138
|
+
error ||= e
|
|
139
|
+
end
|
|
140
|
+
raise error if error
|
|
117
141
|
end
|
|
118
142
|
|
|
119
143
|
# Number of known contexts.
|
|
@@ -135,7 +159,7 @@ module Ferrum
|
|
|
135
159
|
def subscribe_attached_target(client)
|
|
136
160
|
client.on("Target.attachedToTarget") do |params|
|
|
137
161
|
info, session_id = params.values_at("targetInfo", "sessionId")
|
|
138
|
-
next unless ALLOWED_TARGET_TYPES.include?(info["type"])
|
|
162
|
+
next detach_untracked(session_id) unless ALLOWED_TARGET_TYPES.include?(info["type"])
|
|
139
163
|
|
|
140
164
|
context_id = info["browserContextId"]
|
|
141
165
|
add_context(context_id)
|
|
@@ -154,8 +178,7 @@ module Ferrum
|
|
|
154
178
|
context_id = info["browserContextId"]
|
|
155
179
|
add_context(context_id)
|
|
156
180
|
|
|
157
|
-
if info["type"] == "iframe" &&
|
|
158
|
-
(target = @contexts[context_id]&.find_target { |t| t.connected? && t.page.frame_by(id: info["targetId"]) })
|
|
181
|
+
if info["type"] == "iframe" && (target = find_frame_owner(context_id, info["targetId"]))
|
|
159
182
|
@contexts[context_id]&.add_target(session_id: target.session_id, params: info)
|
|
160
183
|
else
|
|
161
184
|
@contexts[context_id]&.add_target(params: info)
|
|
@@ -181,6 +204,12 @@ module Ferrum
|
|
|
181
204
|
end
|
|
182
205
|
end
|
|
183
206
|
|
|
207
|
+
def find_frame_owner(context_id, frame_id)
|
|
208
|
+
@contexts[context_id]&.find_target do |target|
|
|
209
|
+
(target.page? || target.iframe?) && target.connected? && target.page.frame_by(id: frame_id)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
184
213
|
def rearm_auto_attach(session_id, type)
|
|
185
214
|
return unless RECURSIVE_AUTO_ATTACH_TYPES.include?(type)
|
|
186
215
|
|
|
@@ -201,6 +230,16 @@ module Ferrum
|
|
|
201
230
|
end
|
|
202
231
|
end
|
|
203
232
|
|
|
233
|
+
# Auto-attach pauses every target it attaches to, including the types
|
|
234
|
+
# we don't track: Chrome's own `browser_ui` targets, for instance. We
|
|
235
|
+
# never resume those, so left alone they stay attached and paused for
|
|
236
|
+
# the lifetime of the browser. Resume them and drop the session.
|
|
237
|
+
def detach_untracked(session_id)
|
|
238
|
+
detach(session_id)
|
|
239
|
+
rescue BrowserError, TimeoutError
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
|
|
204
243
|
# Attaching keeps a service worker alive forever, so unless the caller
|
|
205
244
|
# explicitly asked to connect to it (via Context#attach_target), we
|
|
206
245
|
# just resume it and let go.
|
|
@@ -208,7 +247,7 @@ module Ferrum
|
|
|
208
247
|
return if @manually_attached.delete(target.id)
|
|
209
248
|
|
|
210
249
|
detach(session_id)
|
|
211
|
-
rescue BrowserError
|
|
250
|
+
rescue BrowserError, TimeoutError
|
|
212
251
|
nil
|
|
213
252
|
end
|
|
214
253
|
|
|
@@ -217,7 +256,7 @@ module Ferrum
|
|
|
217
256
|
# only then resumes the debugger itself.
|
|
218
257
|
def connect_worker(target)
|
|
219
258
|
target.worker
|
|
220
|
-
rescue BrowserError
|
|
259
|
+
rescue BrowserError, TimeoutError
|
|
221
260
|
nil
|
|
222
261
|
end
|
|
223
262
|
|
|
@@ -241,9 +280,29 @@ module Ferrum
|
|
|
241
280
|
def add_context(context_id)
|
|
242
281
|
return if @contexts[context_id]
|
|
243
282
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
283
|
+
@contexts[context_id] = Context.new(@client, self, context_id)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# The browser's implicit context, the context Chrome uses for its startup window.
|
|
287
|
+
# We register it so events for that window are routed to it.
|
|
288
|
+
# `Target.getBrowserContexts` reports every context created with
|
|
289
|
+
# `Target.createBrowserContext`, ours and those of anyone else driving the same
|
|
290
|
+
# browser, so the context holding targets that it doesn't report is the implicit
|
|
291
|
+
# one. Chrome also reports its id as `defaultBrowserContextId`, but only since
|
|
292
|
+
# 145 and only as an experimental field, so we don't rely on it.
|
|
293
|
+
#
|
|
294
|
+
# Returns nil if the browser has no startup window, which happens when it is
|
|
295
|
+
# launched with `--no-startup-window` (used for `incognito: true`). In that case,
|
|
296
|
+
# we create our own context instead.
|
|
297
|
+
#
|
|
298
|
+
# @return [Context, nil]
|
|
299
|
+
def find_implicit_context
|
|
300
|
+
created_ids = @client.command("Target.getBrowserContexts")["browserContextIds"]
|
|
301
|
+
target_infos = @client.command("Target.getTargets")["targetInfos"]
|
|
302
|
+
implicit_id = (target_infos.filter_map { |t| t["browserContextId"] } - created_ids).first
|
|
303
|
+
return unless implicit_id
|
|
304
|
+
|
|
305
|
+
@contexts[implicit_id] = Context.new(@client, self, implicit_id, implicit: true)
|
|
247
306
|
end
|
|
248
307
|
end
|
|
249
308
|
end
|
data/lib/ferrum/downloads.rb
CHANGED
|
@@ -62,8 +62,9 @@ module Ferrum
|
|
|
62
62
|
raise ArgumentError unless VALID_BEHAVIOR.include?(behavior.to_sym)
|
|
63
63
|
raise Error, "supply absolute path for `:save_path` option" unless Pathname.new(save_path.to_s).absolute?
|
|
64
64
|
|
|
65
|
+
options = { browserContextId: @page.context_id } if @page.context_id
|
|
65
66
|
@page.command("Browser.setDownloadBehavior",
|
|
66
|
-
|
|
67
|
+
**Hash(options),
|
|
67
68
|
downloadPath: save_path,
|
|
68
69
|
behavior: behavior,
|
|
69
70
|
eventsEnabled: true)
|
data/lib/ferrum/errors.rb
CHANGED
|
@@ -40,18 +40,50 @@ module Ferrum
|
|
|
40
40
|
|
|
41
41
|
# Raised when waiting for a response from the browser times out.
|
|
42
42
|
class TimeoutError < Error
|
|
43
|
+
# The CDP method that didn't answer in time, `nil` when the wait wasn't for a command.
|
|
43
44
|
#
|
|
44
|
-
#
|
|
45
|
+
# @return [String, nil]
|
|
46
|
+
attr_reader :command
|
|
47
|
+
|
|
48
|
+
# The session the command was sent to, `nil` for the browser-wide session.
|
|
49
|
+
#
|
|
50
|
+
# @return [String, nil]
|
|
51
|
+
attr_reader :session_id
|
|
52
|
+
|
|
53
|
+
#
|
|
54
|
+
# @param [String, nil] command
|
|
55
|
+
# The CDP method that timed out.
|
|
56
|
+
#
|
|
57
|
+
# @param [String, nil] session_id
|
|
58
|
+
# The session it was sent to.
|
|
59
|
+
#
|
|
60
|
+
def initialize(command = nil, session_id: nil)
|
|
61
|
+
@command = command
|
|
62
|
+
@session_id = session_id
|
|
63
|
+
super()
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
#
|
|
67
|
+
# Explains that waiting for a response timed out, naming the command when
|
|
68
|
+
# there was one.
|
|
45
69
|
#
|
|
46
70
|
# @return [String]
|
|
47
71
|
#
|
|
48
72
|
def message
|
|
49
|
-
"Timed out waiting for
|
|
73
|
+
"Timed out waiting for #{awaited}. It's possible that this happened " \
|
|
50
74
|
"because something took a very long time (for example a page load " \
|
|
51
75
|
"was slow). If so, setting the :timeout option to a higher value might " \
|
|
52
76
|
"help. If this happened on an internal protocol call instead, try " \
|
|
53
77
|
"raising :protocol_timeout."
|
|
54
78
|
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def awaited
|
|
83
|
+
return "response" unless @command
|
|
84
|
+
|
|
85
|
+
@session_id ? "a response to #{@command} (session #{@session_id})" : "a response to #{@command}"
|
|
86
|
+
end
|
|
55
87
|
end
|
|
56
88
|
|
|
57
89
|
# Raised when an evaluated script takes too long to return a value.
|
|
@@ -77,6 +109,18 @@ module Ferrum
|
|
|
77
109
|
end
|
|
78
110
|
end
|
|
79
111
|
|
|
112
|
+
# Raised when connecting to an already running browser, but its
|
|
113
|
+
# `/json/version` endpoint doesn't answer with the websocket url.
|
|
114
|
+
class NoWebSocketUrlError < Error
|
|
115
|
+
attr_reader :url
|
|
116
|
+
|
|
117
|
+
def initialize(url)
|
|
118
|
+
@url = url
|
|
119
|
+
super("Couldn't get a websocket url out of #{url}, `/json/version` there didn't answer with JSON. " \
|
|
120
|
+
"Check the browser is reachable at that url and that any credentials it needs are part of it")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
80
124
|
# Raised when trying to interact with a browser process that has died or
|
|
81
125
|
# a window that has already been closed.
|
|
82
126
|
class DeadBrowserError < Error
|
data/lib/ferrum/frame/dom.rb
CHANGED
|
@@ -116,7 +116,8 @@ module Ferrum
|
|
|
116
116
|
# Returns the element in which the window is embedded.
|
|
117
117
|
#
|
|
118
118
|
# @return [Node, nil]
|
|
119
|
-
# The element in which the window is embedded
|
|
119
|
+
# The element in which the window is embedded, `nil` for the main frame
|
|
120
|
+
# and for a frame from a different origin than its parent.
|
|
120
121
|
#
|
|
121
122
|
# @example
|
|
122
123
|
# browser.go_to("https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe")
|
|
@@ -243,6 +244,54 @@ module Ferrum
|
|
|
243
244
|
evaluate_func(expr, selector, within)
|
|
244
245
|
end
|
|
245
246
|
|
|
247
|
+
#
|
|
248
|
+
# Finds a node by using a CSS path or XPath selector, retrying until
|
|
249
|
+
# it appears or `timeout` elapses. Pass exactly one of `css`/`xpath`;
|
|
250
|
+
# each attempt delegates to {#at_css}/{#at_xpath} respectively, so it
|
|
251
|
+
# matches their behavior exactly.
|
|
252
|
+
#
|
|
253
|
+
# @param [String, nil] css
|
|
254
|
+
# The CSS path selector. Mutually exclusive with `xpath`.
|
|
255
|
+
#
|
|
256
|
+
# @param [String, nil] xpath
|
|
257
|
+
# The XPath selector. Mutually exclusive with `css`.
|
|
258
|
+
#
|
|
259
|
+
# @param [Node, nil] within
|
|
260
|
+
# The parent node to search within.
|
|
261
|
+
#
|
|
262
|
+
# @param [Float, Integer] timeout
|
|
263
|
+
# How long to keep retrying, in seconds.
|
|
264
|
+
#
|
|
265
|
+
# @param [Float, Integer] interval
|
|
266
|
+
# How long to sleep between attempts, in seconds.
|
|
267
|
+
#
|
|
268
|
+
# @return [Node, nil]
|
|
269
|
+
# The matching node, or `nil` if `timeout` elapses without a match.
|
|
270
|
+
#
|
|
271
|
+
# @raise [ArgumentError]
|
|
272
|
+
# If neither or both of `css`/`xpath` are given.
|
|
273
|
+
#
|
|
274
|
+
# @example
|
|
275
|
+
# browser.go_to("https://github.com/")
|
|
276
|
+
# browser.wait_for_selector(css: "a[aria-label='Issues you created']") # => Node
|
|
277
|
+
#
|
|
278
|
+
def wait_for_selector(css: nil, xpath: nil, within: nil, timeout: @page.timeout, interval: 0.05)
|
|
279
|
+
raise ArgumentError, "pass either `css:` or `xpath:`" if css && xpath
|
|
280
|
+
|
|
281
|
+
selector, method_name = css ? [css, :at_css] : [xpath, :at_xpath]
|
|
282
|
+
raise ArgumentError, "pass either `css:` or `xpath:`" unless selector
|
|
283
|
+
|
|
284
|
+
start = Utils::ElapsedTime.monotonic_time
|
|
285
|
+
|
|
286
|
+
until (node = send(method_name, selector, within: within))
|
|
287
|
+
return nil if Utils::ElapsedTime.timeout?(start, timeout)
|
|
288
|
+
|
|
289
|
+
sleep(interval)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
node
|
|
293
|
+
end
|
|
294
|
+
|
|
246
295
|
#
|
|
247
296
|
# Adds a `<script>` tag to the document.
|
|
248
297
|
#
|
data/lib/ferrum/frame/runtime.rb
CHANGED
|
@@ -146,7 +146,8 @@ module Ferrum
|
|
|
146
146
|
private
|
|
147
147
|
|
|
148
148
|
def call(expression:, arguments: [], on: nil, wait: 0, handle: true, **options)
|
|
149
|
-
|
|
149
|
+
# do not rescue -> retry if we operate on an existing node
|
|
150
|
+
errors = on ? [] : [NodeNotFoundError, NoExecutionContextError]
|
|
150
151
|
|
|
151
152
|
Utils::Attempt.with_retry(errors: errors, max: INTERMITTENT_ATTEMPTS, wait: INTERMITTENT_SLEEP) do
|
|
152
153
|
params = options.dup
|
|
@@ -185,7 +186,7 @@ module Ferrum
|
|
|
185
186
|
end
|
|
186
187
|
end
|
|
187
188
|
|
|
188
|
-
def handle_response(response)
|
|
189
|
+
def handle_response(response, check_cyclic: true)
|
|
189
190
|
case response["type"]
|
|
190
191
|
when "boolean", "number", "string"
|
|
191
192
|
response["value"]
|
|
@@ -201,10 +202,10 @@ module Ferrum
|
|
|
201
202
|
description = @page.command("DOM.describeNode", objectId: object_id)["node"]
|
|
202
203
|
Node.new(self, @page.target_id, description, object_id: object_id)
|
|
203
204
|
when "array"
|
|
204
|
-
reduce_props(object_id, []) do |memo, key, value|
|
|
205
|
+
reduce_props(object_id, [], check_cyclic: check_cyclic) do |memo, key, value|
|
|
205
206
|
next(memo) unless Integer(key, exception: false)
|
|
206
207
|
|
|
207
|
-
value = value["objectId"] ? handle_response(value) : value["value"]
|
|
208
|
+
value = value["objectId"] ? handle_response(value, check_cyclic: false) : value["value"]
|
|
208
209
|
memo.insert(key.to_i, value)
|
|
209
210
|
end.compact
|
|
210
211
|
when "date"
|
|
@@ -212,8 +213,8 @@ module Ferrum
|
|
|
212
213
|
when "null"
|
|
213
214
|
nil
|
|
214
215
|
else
|
|
215
|
-
reduce_props(object_id, {}) do |memo, key, value|
|
|
216
|
-
value = value["objectId"] ? handle_response(value) : value["value"]
|
|
216
|
+
reduce_props(object_id, {}, check_cyclic: check_cyclic) do |memo, key, value|
|
|
217
|
+
value = value["objectId"] ? handle_response(value, check_cyclic: false) : value["value"]
|
|
217
218
|
memo.merge(key => value)
|
|
218
219
|
end
|
|
219
220
|
end
|
|
@@ -233,8 +234,8 @@ module Ferrum
|
|
|
233
234
|
end
|
|
234
235
|
end
|
|
235
236
|
|
|
236
|
-
def reduce_props(object_id, to)
|
|
237
|
-
if cyclic?(object_id).dig("result", "value")
|
|
237
|
+
def reduce_props(object_id, to, check_cyclic: true)
|
|
238
|
+
if check_cyclic && cyclic?(object_id).dig("result", "value")
|
|
238
239
|
to.is_a?(Array) ? [cyclic_object] : cyclic_object
|
|
239
240
|
else
|
|
240
241
|
props = @page.command("Runtime.getProperties", ownProperties: true, objectId: object_id)
|