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.
@@ -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
- def send_message(data)
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 { @driver.text(json) }
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
- @sock.write(data)
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
- safely_parse_escaped_json(data)
227
+ safely_parse_lone_surrogates(data)
165
228
  end
166
229
 
167
- def safely_parse_escaped_json(data)
168
- unescaped_unicode =
169
- data.gsub(/\\u([\da-fA-F]{4})/) { |_| [::Regexp.last_match(1)].pack("H*").unpack("n*").pack("U*") }
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 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
@@ -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"
@@ -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
@@ -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
  #
@@ -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)