ferrum 0.18.0 → 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 +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 -2
- 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 +65 -3
- data/lib/ferrum/client.rb +28 -6
- 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 +48 -0
- data/lib/ferrum/frame/runtime.rb +9 -8
- data/lib/ferrum/interceptable.rb +25 -4
- 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
|
@@ -106,13 +106,26 @@ module Ferrum
|
|
|
106
106
|
# @param [Hash] data
|
|
107
107
|
# The message to send, must include an `:id` key.
|
|
108
108
|
#
|
|
109
|
+
# @param [Numeric, nil] timeout
|
|
110
|
+
# How long the browser may go without accepting any of the frame's
|
|
111
|
+
# bytes. `nil` waits for as long as it takes.
|
|
112
|
+
#
|
|
109
113
|
# @return [void]
|
|
110
114
|
#
|
|
111
|
-
|
|
115
|
+
# @raise [Ferrum::DeadBrowserError]
|
|
116
|
+
# If the browser accepted no data for `timeout` seconds. The connection
|
|
117
|
+
# is closed, since the frame was cut off midway.
|
|
118
|
+
#
|
|
119
|
+
def send_message(data, timeout = nil)
|
|
112
120
|
@screenshot_commands[data[:id]] = true if SKIP_LOGGING_SCREENSHOTS
|
|
113
121
|
|
|
114
122
|
json = data.to_json
|
|
115
|
-
@driver_mutex.synchronize
|
|
123
|
+
@driver_mutex.synchronize do
|
|
124
|
+
@write_timeout = timeout
|
|
125
|
+
@driver.text(json)
|
|
126
|
+
ensure
|
|
127
|
+
@write_timeout = nil
|
|
128
|
+
end
|
|
116
129
|
@logger&.puts("\n\n▶ #{Utils::ElapsedTime.elapsed_time} #{json}")
|
|
117
130
|
end
|
|
118
131
|
|
|
@@ -121,13 +134,28 @@ module Ferrum
|
|
|
121
134
|
# `websocket-driver` to emit frames. Closes {#messages} instead of
|
|
122
135
|
# raising if the connection has already been torn down.
|
|
123
136
|
#
|
|
137
|
+
# A browser that stops reading its socket (hung, or stopped) would block
|
|
138
|
+
# a plain write forever, holding the lock every other command needs to be
|
|
139
|
+
# sent. So the write gives up once the browser accepts no data for the
|
|
140
|
+
# timeout of the command being sent.
|
|
141
|
+
#
|
|
124
142
|
# @param [String] data
|
|
125
143
|
# The raw bytes to write.
|
|
126
144
|
#
|
|
127
145
|
# @return [void]
|
|
128
146
|
#
|
|
147
|
+
# @raise [Ferrum::DeadBrowserError]
|
|
148
|
+
# If the browser accepted no data for the command's timeout.
|
|
149
|
+
#
|
|
129
150
|
def write(data)
|
|
130
|
-
|
|
151
|
+
until data.empty?
|
|
152
|
+
written = @sock.write_nonblock(data, exception: false)
|
|
153
|
+
if written.is_a?(Integer)
|
|
154
|
+
data = data.byteslice(written..)
|
|
155
|
+
elsif !wait_for_socket(written)
|
|
156
|
+
abandon_stalled_write
|
|
157
|
+
end
|
|
158
|
+
end
|
|
131
159
|
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException
|
|
132
160
|
@messages.close
|
|
133
161
|
end
|
|
@@ -141,8 +169,38 @@ module Ferrum
|
|
|
141
169
|
@driver_mutex.synchronize { @driver.close }
|
|
142
170
|
end
|
|
143
171
|
|
|
172
|
+
#
|
|
173
|
+
# Closes the underlying socket whether or not the close handshake ever
|
|
174
|
+
# completed.
|
|
175
|
+
#
|
|
176
|
+
# {#close} only sends a close frame; the socket itself is closed by
|
|
177
|
+
# {#on_close}, which runs when the browser answers with a close frame of
|
|
178
|
+
# its own. A browser that is already dead never answers, so without this
|
|
179
|
+
# the socket stays open until GC finalizes it -- long enough to exhaust
|
|
180
|
+
# the process's file descriptors when browsers are restarted in a loop.
|
|
181
|
+
#
|
|
182
|
+
# @return [void]
|
|
183
|
+
#
|
|
184
|
+
def force_close
|
|
185
|
+
@sock.close unless @sock.closed?
|
|
186
|
+
rescue IOError, SystemCallError
|
|
187
|
+
# nop
|
|
188
|
+
end
|
|
189
|
+
|
|
144
190
|
private
|
|
145
191
|
|
|
192
|
+
def wait_for_socket(state)
|
|
193
|
+
readers, writers = state == :wait_readable ? [[@sock], nil] : [nil, [@sock]]
|
|
194
|
+
IO.select(readers, writers, nil, @write_timeout)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def abandon_stalled_write
|
|
198
|
+
timeout = @write_timeout
|
|
199
|
+
@messages.close
|
|
200
|
+
@sock.close
|
|
201
|
+
raise DeadBrowserError, "Browser accepted no data for #{timeout}s and is likely stuck, connection closed"
|
|
202
|
+
end
|
|
203
|
+
|
|
146
204
|
def start
|
|
147
205
|
@thread = Utils::Thread.spawn do
|
|
148
206
|
loop do
|
|
@@ -152,6 +210,10 @@ module Ferrum
|
|
|
152
210
|
@driver_mutex.synchronize { @driver.parse(data) }
|
|
153
211
|
end
|
|
154
212
|
rescue EOFError, Errno::ECONNRESET, Errno::EPIPE, IOError # rubocop:disable Lint/ShadowedException
|
|
213
|
+
# The browser went away, nothing to report.
|
|
214
|
+
rescue StandardError => e
|
|
215
|
+
warn("Ferrum: websocket reader stopped, #{e.class}: #{e.message}\n #{e.backtrace&.first}")
|
|
216
|
+
ensure
|
|
155
217
|
@messages.close
|
|
156
218
|
end
|
|
157
219
|
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
|
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
|
@@ -243,6 +243,54 @@ module Ferrum
|
|
|
243
243
|
evaluate_func(expr, selector, within)
|
|
244
244
|
end
|
|
245
245
|
|
|
246
|
+
#
|
|
247
|
+
# Finds a node by using a CSS path or XPath selector, retrying until
|
|
248
|
+
# it appears or `timeout` elapses. Pass exactly one of `css`/`xpath`;
|
|
249
|
+
# each attempt delegates to {#at_css}/{#at_xpath} respectively, so it
|
|
250
|
+
# matches their behavior exactly.
|
|
251
|
+
#
|
|
252
|
+
# @param [String, nil] css
|
|
253
|
+
# The CSS path selector. Mutually exclusive with `xpath`.
|
|
254
|
+
#
|
|
255
|
+
# @param [String, nil] xpath
|
|
256
|
+
# The XPath selector. Mutually exclusive with `css`.
|
|
257
|
+
#
|
|
258
|
+
# @param [Node, nil] within
|
|
259
|
+
# The parent node to search within.
|
|
260
|
+
#
|
|
261
|
+
# @param [Float, Integer] timeout
|
|
262
|
+
# How long to keep retrying, in seconds.
|
|
263
|
+
#
|
|
264
|
+
# @param [Float, Integer] interval
|
|
265
|
+
# How long to sleep between attempts, in seconds.
|
|
266
|
+
#
|
|
267
|
+
# @return [Node, nil]
|
|
268
|
+
# The matching node, or `nil` if `timeout` elapses without a match.
|
|
269
|
+
#
|
|
270
|
+
# @raise [ArgumentError]
|
|
271
|
+
# If neither or both of `css`/`xpath` are given.
|
|
272
|
+
#
|
|
273
|
+
# @example
|
|
274
|
+
# browser.go_to("https://github.com/")
|
|
275
|
+
# browser.wait_for_selector(css: "a[aria-label='Issues you created']") # => Node
|
|
276
|
+
#
|
|
277
|
+
def wait_for_selector(css: nil, xpath: nil, within: nil, timeout: @page.timeout, interval: 0.05)
|
|
278
|
+
raise ArgumentError, "pass either `css:` or `xpath:`" if css && xpath
|
|
279
|
+
|
|
280
|
+
selector, method_name = css ? [css, :at_css] : [xpath, :at_xpath]
|
|
281
|
+
raise ArgumentError, "pass either `css:` or `xpath:`" unless selector
|
|
282
|
+
|
|
283
|
+
start = Utils::ElapsedTime.monotonic_time
|
|
284
|
+
|
|
285
|
+
until (node = send(method_name, selector, within: within))
|
|
286
|
+
return nil if Utils::ElapsedTime.timeout?(start, timeout)
|
|
287
|
+
|
|
288
|
+
sleep(interval)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
node
|
|
292
|
+
end
|
|
293
|
+
|
|
246
294
|
#
|
|
247
295
|
# Adds a `<script>` tag to the document.
|
|
248
296
|
#
|
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)
|
data/lib/ferrum/interceptable.rb
CHANGED
|
@@ -2,14 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
module Ferrum
|
|
4
4
|
# Shared by {Page} and {Worker}: subscribes to the CDP events behind the
|
|
5
|
-
# `:request` (Fetch-domain request interception)
|
|
6
|
-
#
|
|
7
|
-
#
|
|
5
|
+
# `:request` (Fetch-domain request interception), `:response` (a request's
|
|
6
|
+
# response has fully loaded) and `:auth` (proxy/basic auth challenges)
|
|
7
|
+
# pseudo-events, on top of the includer's own `client` and `network`.
|
|
8
|
+
# Anything else is passed straight through to `client`.
|
|
8
9
|
module Interceptable
|
|
9
|
-
# Subscribes to a CDP event, or to `:request`/`:auth`.
|
|
10
|
+
# Subscribes to a CDP event, or to `:request`/`:response`/`:auth`.
|
|
10
11
|
#
|
|
11
12
|
# @param [Symbol, String] name
|
|
12
13
|
#
|
|
14
|
+
# @yieldparam [Network::InterceptedRequest, Network::Exchange, Network::AuthRequest, Hash] arg
|
|
15
|
+
# For `:request`, the intercepted {Network::InterceptedRequest}. For
|
|
16
|
+
# `:response`, the finished {Network::Exchange}. For `:auth`, the
|
|
17
|
+
# {Network::AuthRequest} challenge. For any other (raw CDP) event name,
|
|
18
|
+
# the event's params `Hash`.
|
|
19
|
+
#
|
|
20
|
+
# @yieldparam [Integer] index
|
|
21
|
+
# This callback's position among all callbacks currently registered for
|
|
22
|
+
# the event.
|
|
23
|
+
#
|
|
24
|
+
# @yieldparam [Integer] total
|
|
25
|
+
# Total number of callbacks currently registered for the event.
|
|
26
|
+
#
|
|
13
27
|
# @return [Integer]
|
|
14
28
|
# The subscription id, used to unsubscribe via {#off}.
|
|
15
29
|
def on(name, &block)
|
|
@@ -21,6 +35,11 @@ module Ferrum
|
|
|
21
35
|
exchange.intercepted_request = request
|
|
22
36
|
block.call(request, index, total)
|
|
23
37
|
end
|
|
38
|
+
when :response
|
|
39
|
+
client.on("Network.loadingFinished") do |params, index, total|
|
|
40
|
+
exchange = network.select(params["requestId"]).last
|
|
41
|
+
block.call(exchange, index, total) if exchange
|
|
42
|
+
end
|
|
24
43
|
when :auth
|
|
25
44
|
client.on("Fetch.authRequired") do |params, index, total|
|
|
26
45
|
request = Network::AuthRequest.new(self, params)
|
|
@@ -43,6 +62,8 @@ module Ferrum
|
|
|
43
62
|
case name
|
|
44
63
|
when :request
|
|
45
64
|
client.off("Fetch.requestPaused", id)
|
|
65
|
+
when :response
|
|
66
|
+
client.off("Network.loadingFinished", id)
|
|
46
67
|
when :auth
|
|
47
68
|
client.off("Fetch.authRequired", id)
|
|
48
69
|
else
|
|
@@ -32,9 +32,9 @@ module Ferrum
|
|
|
32
32
|
A1: { width: 23.40, height: 33.10 },
|
|
33
33
|
A2: { width: 16.54, height: 23.40 },
|
|
34
34
|
A3: { width: 11.70, height: 16.54 },
|
|
35
|
-
A4: { width:
|
|
36
|
-
A5: { width:
|
|
37
|
-
A6: { width:
|
|
35
|
+
A4: { width: 8.27, height: 11.70 },
|
|
36
|
+
A5: { width: 5.83, height: 8.27 },
|
|
37
|
+
A6: { width: 4.13, height: 5.83 }
|
|
38
38
|
}.freeze
|
|
39
39
|
|
|
40
40
|
#
|