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.
@@ -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
- def send_message(data)
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 { @driver.text(json) }
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
- @sock.write(data)
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 response. Defaults to `protocol_timeout`.
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
- @ws.send_message(message)
225
- data = pending.value!(timeout || protocol_timeout)
226
- @pendings.delete(message[:id])
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
@@ -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
- target_id = @client.command("Target.createTarget", browserContextId: @id, url: "about:blank")["targetId"]
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
@@ -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 first context, created lazily.
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
- context.close_targets_connection
97
- @client.command("Target.disposeBrowserContext", browserContextId: context.id)
98
- @contexts.delete(context_id)
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.each_key { |id| dispose(id) if context_ids.include?(id) }
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
- context = Context.new(@client, self, context_id)
245
- @contexts[context_id] = context
246
- @default_context ||= context # rubocop:disable Naming/MemoizedInstanceVariableName
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
@@ -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
- browserContextId: @page.context_id,
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
- # Explains that waiting for a response timed out.
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 response. It's possible that this happened " \
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
@@ -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
  #
@@ -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
- errors = [NodeNotFoundError, NoExecutionContextError]
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)
@@ -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) and `:auth` (proxy/basic
6
- # auth challenges) pseudo-events, on top of the includer's own `client`
7
- # and `network`. Anything else is passed straight through to `client`.
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: 8.27, height: 11.70 },
36
- A5: { width: 5.83, height: 8.27 },
37
- A6: { width: 4.13, height: 5.83 }
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
  #