ferrum 0.17.2 → 0.18.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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/ferrum/accessibility/ax_node.rb +108 -0
  4. data/lib/ferrum/accessibility.rb +106 -0
  5. data/lib/ferrum/browser/binary.rb +41 -0
  6. data/lib/ferrum/browser/command.rb +20 -0
  7. data/lib/ferrum/browser/options/base.rb +67 -0
  8. data/lib/ferrum/browser/options/chrome.rb +36 -1
  9. data/lib/ferrum/browser/options/firefox.rb +32 -0
  10. data/lib/ferrum/browser/options.rb +41 -2
  11. data/lib/ferrum/browser/process.rb +51 -0
  12. data/lib/ferrum/browser/xvfb.rb +24 -0
  13. data/lib/ferrum/browser.rb +32 -10
  14. data/lib/ferrum/client/subscriber.rb +58 -0
  15. data/lib/ferrum/client/web_socket.rb +62 -11
  16. data/lib/ferrum/client.rb +199 -8
  17. data/lib/ferrum/context.rb +97 -4
  18. data/lib/ferrum/contexts.rb +119 -9
  19. data/lib/ferrum/cookies/cookie.rb +6 -0
  20. data/lib/ferrum/cookies.rb +5 -0
  21. data/lib/ferrum/dialog.rb +18 -2
  22. data/lib/ferrum/downloads.rb +52 -0
  23. data/lib/ferrum/errors.rb +58 -3
  24. data/lib/ferrum/frame/dom.rb +17 -0
  25. data/lib/ferrum/frame/runtime.rb +48 -7
  26. data/lib/ferrum/frame.rb +58 -1
  27. data/lib/ferrum/headers.rb +6 -0
  28. data/lib/ferrum/interceptable.rb +62 -0
  29. data/lib/ferrum/keyboard.rb +25 -0
  30. data/lib/ferrum/mouse.rb +6 -0
  31. data/lib/ferrum/network/auth_request.rb +81 -2
  32. data/lib/ferrum/network/error.rb +15 -0
  33. data/lib/ferrum/network/exchange.rb +10 -0
  34. data/lib/ferrum/network/intercepted_request.rb +94 -2
  35. data/lib/ferrum/network/request.rb +1 -1
  36. data/lib/ferrum/network/response.rb +2 -0
  37. data/lib/ferrum/network.rb +121 -9
  38. data/lib/ferrum/node.rb +305 -15
  39. data/lib/ferrum/page/animation.rb +5 -0
  40. data/lib/ferrum/page/frames.rb +69 -7
  41. data/lib/ferrum/page/screencast.rb +5 -0
  42. data/lib/ferrum/page/screenshot.rb +52 -20
  43. data/lib/ferrum/page/stream.rb +56 -0
  44. data/lib/ferrum/page/tracing.rb +6 -0
  45. data/lib/ferrum/page.rb +139 -42
  46. data/lib/ferrum/proxy.rb +52 -2
  47. data/lib/ferrum/rgba.rb +10 -0
  48. data/lib/ferrum/target.rb +124 -1
  49. data/lib/ferrum/utils/attempt.rb +20 -0
  50. data/lib/ferrum/utils/elapsed_time.rb +38 -0
  51. data/lib/ferrum/utils/event.rb +14 -0
  52. data/lib/ferrum/utils/platform.rb +21 -0
  53. data/lib/ferrum/utils/thread.rb +12 -0
  54. data/lib/ferrum/version.rb +1 -1
  55. data/lib/ferrum/worker.rb +125 -0
  56. data/lib/ferrum.rb +7 -0
  57. metadata +6 -16
data/lib/ferrum/client.rb CHANGED
@@ -7,9 +7,29 @@ require "ferrum/client/web_socket"
7
7
  require "ferrum/utils/thread"
8
8
 
9
9
  module Ferrum
10
+ #
11
+ # A thin wrapper around {Client} that scopes commands and events to a
12
+ # single CDP session (a `sessionId` obtained via `Target.attachToTarget`).
13
+ # Targets connect through one of these rather than the top-level {Client}
14
+ # directly, so their commands/events don't leak into other sessions.
15
+ # Method calls not defined here are forwarded to the underlying {Client}.
16
+ #
10
17
  class SessionClient
11
18
  attr_reader :client, :session_id
12
19
 
20
+ #
21
+ # Builds the internal event key used to route an event to a specific
22
+ # session, joining the CDP event name with a session id (or leaving it
23
+ # session-less when `session_id` is `nil`).
24
+ #
25
+ # @param [String] event
26
+ # The CDP event name, e.g. `"Page.loadEventFired"`.
27
+ #
28
+ # @param [String, nil] session_id
29
+ # The target session id, or `nil` for the browser-wide session.
30
+ #
31
+ # @return [String]
32
+ #
13
33
  def self.event_name(event, session_id)
14
34
  [event, session_id].compact.join("_")
15
35
  end
@@ -19,31 +39,93 @@ module Ferrum
19
39
  @session_id = session_id
20
40
  end
21
41
 
22
- def command(method, async: false, **params)
42
+ #
43
+ # Sends a CDP command scoped to this session.
44
+ #
45
+ # @param [String] method
46
+ # The CDP method name, e.g. `"Page.navigate"`.
47
+ #
48
+ # @param [Boolean] async
49
+ # Whether to send the command without waiting for a response.
50
+ #
51
+ # @param [Hash] params
52
+ # The command's parameters.
53
+ #
54
+ # @param [Numeric, nil] timeout
55
+ # How long to wait for this command's response, overriding
56
+ # {Browser::Options#protocol_timeout}. See {Client#send_message}.
57
+ #
58
+ # @return [Boolean, Hash]
59
+ # `true` when sent asynchronously, otherwise the command's result.
60
+ #
61
+ def command(method, async: false, timeout: nil, **params)
23
62
  message = build_message(method, params)
24
- @client.send_message(message, async: async)
63
+ @client.send_message(message, async: async, timeout: timeout)
25
64
  end
26
65
 
66
+ #
67
+ # Subscribes to a CDP event scoped to this session.
68
+ #
69
+ # @param [String] event
70
+ # The CDP event name.
71
+ #
72
+ # @return [Integer]
73
+ # The subscription id, used to unsubscribe via {#off}.
74
+ #
27
75
  def on(event, &)
28
76
  @client.on(event_name(event), &)
29
77
  end
30
78
 
79
+ #
80
+ # Unsubscribes from a CDP event scoped to this session.
81
+ #
82
+ # @param [String] event
83
+ # The CDP event name.
84
+ #
85
+ # @param [Integer] id
86
+ # The subscription id returned by {#on}.
87
+ #
88
+ # @return [void]
89
+ #
31
90
  def off(event, id)
32
91
  @client.off(event_name(event), id)
33
92
  end
34
93
 
94
+ # Whether there's at least one callback registered for the event, scoped
95
+ # to this session.
96
+ #
97
+ # @param [String] event
98
+ # The CDP event name.
99
+ #
100
+ # @return [Boolean]
35
101
  def subscribed?(event)
36
102
  @client.subscribed?(event_name(event))
37
103
  end
38
104
 
105
+ # Supports {#method_missing} delegation by reporting the underlying
106
+ # {Client}'s methods as responded to.
107
+ #
108
+ # @return [Boolean]
39
109
  def respond_to_missing?(name, include_private)
40
110
  @client.respond_to?(name, include_private)
41
111
  end
42
112
 
113
+ #
114
+ # Delegates any method not defined on `SessionClient` to the underlying
115
+ # {Client}, e.g. `subscribed?`.
116
+ #
117
+ # @return [untyped]
118
+ #
43
119
  def method_missing(name, ...)
44
120
  @client.send(name, ...)
45
121
  end
46
122
 
123
+ #
124
+ # Removes all event callbacks registered for this session from the
125
+ # underlying client's subscriber.
126
+ #
127
+ # @return [void]
128
+ #
47
129
  def close
48
130
  @client.subscriber.clear(session_id: session_id)
49
131
  end
@@ -59,15 +141,22 @@ module Ferrum
59
141
  end
60
142
  end
61
143
 
144
+ #
145
+ # The low-level CDP client. Owns the {WebSocket} connection to the browser,
146
+ # assigns command ids, matches responses back to their pending commands and
147
+ # dispatches incoming events to the {Subscriber}. {SessionClient} builds on
148
+ # top of it to scope commands/events to a particular target's session.
149
+ #
62
150
  class Client
63
151
  extend Forwardable
64
152
 
65
- delegate %i[timeout timeout=] => :options
153
+ delegate %i[protocol_timeout protocol_timeout=] => :options
66
154
 
67
155
  attr_reader :ws_url, :options, :subscriber
68
156
 
69
157
  def initialize(ws_url, options)
70
158
  @command_id = 0
159
+ @command_id_mutex = Mutex.new
71
160
  @ws_url = ws_url
72
161
  @options = options
73
162
  @pendings = Concurrent::Hash.new
@@ -77,12 +166,55 @@ module Ferrum
77
166
  start
78
167
  end
79
168
 
80
- def command(method, async: false, **params)
169
+ #
170
+ # Sends a CDP command to the browser-wide session.
171
+ #
172
+ # @param [String] method
173
+ # The CDP method name, e.g. `"Target.createTarget"`.
174
+ #
175
+ # @param [Boolean] async
176
+ # Whether to send the command without waiting for a response.
177
+ #
178
+ # @param [Hash] params
179
+ # The command's parameters.
180
+ #
181
+ # @param [Numeric, nil] timeout
182
+ # How long to wait for this command's response, overriding
183
+ # {Browser::Options#protocol_timeout}. See {#send_message}.
184
+ #
185
+ # @return [Boolean, Hash]
186
+ # `true` when sent asynchronously, otherwise the command's result.
187
+ #
188
+ def command(method, async: false, timeout: nil, **params)
81
189
  message = build_message(method, params)
82
- send_message(message, async: async)
190
+ send_message(message, async: async, timeout: timeout)
83
191
  end
84
192
 
85
- def send_message(message, async:)
193
+ #
194
+ # Sends a raw CDP message over the websocket. Synchronous calls block
195
+ # until a matching response arrives, or `timeout` elapses, defaulting to
196
+ # `protocol_timeout` (delegated to {Browser::Options#protocol_timeout}).
197
+ # That default is the transport-level budget for internal CDP bookkeeping
198
+ # (e.g. `Target.createTarget`). {Page#command} overrides
199
+ # this back to `timeout`, or a caller-supplied budget (e.g. `#pdf`/
200
+ # `#screenshot`'s own `timeout:` argument), for the user-facing commands
201
+ # it issues -- some of which (e.g. `Page.navigate`, `Page.printToPDF`)
202
+ # rely on their own response latency to detect a stuck operation.
203
+ #
204
+ # @param [Hash] message
205
+ # The message to send, must include an `:id` key.
206
+ #
207
+ # @param [Boolean] async
208
+ # Whether to return immediately instead of waiting for a response.
209
+ #
210
+ # @param [Numeric, nil] timeout
211
+ # How long to wait for the response. Defaults to `protocol_timeout`.
212
+ #
213
+ # @return [Boolean, Hash]
214
+ # `true` when sent asynchronously, otherwise the parsed `"result"`
215
+ # from the response.
216
+ #
217
+ def send_message(message, async:, timeout: nil)
86
218
  if async
87
219
  @ws.send_message(message)
88
220
  true
@@ -90,7 +222,7 @@ module Ferrum
90
222
  pending = Concurrent::IVar.new
91
223
  @pendings[message[:id]] = pending
92
224
  @ws.send_message(message)
93
- data = pending.value!(timeout)
225
+ data = pending.value!(timeout || protocol_timeout)
94
226
  @pendings.delete(message[:id])
95
227
 
96
228
  raise DeadBrowserError if data.nil? && @ws.messages.closed?
@@ -102,22 +234,63 @@ module Ferrum
102
234
  end
103
235
  end
104
236
 
237
+ #
238
+ # Subscribes to a CDP event.
239
+ #
240
+ # @param [String] event
241
+ # The CDP event name.
242
+ #
243
+ # @return [Integer]
244
+ # The subscription id, used to unsubscribe via {#off}.
245
+ #
105
246
  def on(event, &)
106
247
  @subscriber.on(event, &)
107
248
  end
108
249
 
250
+ #
251
+ # Unsubscribes from a CDP event.
252
+ #
253
+ # @param [String] event
254
+ # The CDP event name.
255
+ #
256
+ # @param [Integer] id
257
+ # The subscription id returned by {#on}.
258
+ #
259
+ # @return [void]
260
+ #
109
261
  def off(event, id)
110
262
  @subscriber.off(event, id)
111
263
  end
112
264
 
265
+ # Whether there's at least one callback registered for the event.
266
+ #
267
+ # @param [String] event
268
+ # The CDP event name.
269
+ #
270
+ # @return [Boolean]
113
271
  def subscribed?(event)
114
272
  @subscriber.subscribed?(event)
115
273
  end
116
274
 
275
+ #
276
+ # Builds a client scoped to a given CDP session, e.g. a browsing
277
+ # context created via `Target.attachToTarget`.
278
+ #
279
+ # @param [String] session_id
280
+ # The CDP session id to scope commands and events to.
281
+ #
282
+ # @return [SessionClient]
283
+ #
117
284
  def session(session_id)
118
285
  SessionClient.new(self, session_id)
119
286
  end
120
287
 
288
+ #
289
+ # Closes the underlying websocket, drops pending commands and stops
290
+ # the message-processing thread and subscriber.
291
+ #
292
+ # @return [void]
293
+ #
121
294
  def close
122
295
  @ws.close
123
296
  # Give a thread some time to handle a tail of messages
@@ -126,6 +299,11 @@ module Ferrum
126
299
  @subscriber.close
127
300
  end
128
301
 
302
+ #
303
+ # Custom inspection that exposes internal state useful for debugging.
304
+ #
305
+ # @return [String]
306
+ #
129
307
  def inspect
130
308
  "#<#{self.class} " \
131
309
  "@command_id=#{@command_id.inspect} " \
@@ -133,6 +311,17 @@ module Ferrum
133
311
  "@ws=#{@ws.inspect}>"
134
312
  end
135
313
 
314
+ #
315
+ # Builds a CDP message hash with a fresh, thread-safe command id.
316
+ #
317
+ # @param [String] method
318
+ # The CDP method name.
319
+ #
320
+ # @param [Hash] params
321
+ # The command's parameters.
322
+ #
323
+ # @return [Hash]
324
+ #
136
325
  def build_message(method, params)
137
326
  { method: method, params: params }.merge(id: next_command_id)
138
327
  end
@@ -154,8 +343,10 @@ module Ferrum
154
343
  end
155
344
  end
156
345
 
346
+ # Locked so two concurrent commands never share an id and read each
347
+ # other's responses from @pendings.
157
348
  def next_command_id
158
- @command_id += 1
349
+ @command_id_mutex.synchronize { @command_id += 1 }
159
350
  end
160
351
 
161
352
  def raise_browser_error(error)
@@ -3,6 +3,13 @@
3
3
  require "ferrum/target"
4
4
 
5
5
  module Ferrum
6
+ #
7
+ # Represents a browser context, i.e. an isolated browsing profile (similar
8
+ # to an incognito window) with its own cookies, cache and storage. Keeps
9
+ # track of the {Target}s that belong to it and connects to them as {Page}s
10
+ # or {Worker}s. Managed by {Contexts}, which owns the browser's collection
11
+ # of contexts and routes CDP target events to the right one.
12
+ #
6
13
  class Context
7
14
  POSITION = %i[first last].freeze
8
15
 
@@ -16,16 +23,43 @@ module Ferrum
16
23
  @pendings = Concurrent::Map.new
17
24
  end
18
25
 
26
+ # The context's first known target, creating one via
27
+ # `Target.createTarget` if none has attached yet.
28
+ #
29
+ # @return [Target]
19
30
  def default_target
20
31
  @default_target ||= create_target
21
32
  end
22
33
 
34
+ # Connects to and returns the {#default_target}'s page.
35
+ #
36
+ # @return [Page]
23
37
  def page
24
38
  default_target.page
25
39
  end
26
40
 
41
+ # All page targets in this context, connected to as {Page}s.
42
+ #
43
+ # @return [Array<Page>]
27
44
  def pages
28
- @targets.values.reject(&:iframe?).map(&:page)
45
+ @targets.values.select(&:page?).map(&:page)
46
+ end
47
+
48
+ # Dedicated and shared workers spawned by any page in this context.
49
+ #
50
+ # @return [Array<Worker>]
51
+ def workers
52
+ @targets.values.select { |t| t.worker? || t.shared_worker? }.map(&:worker)
53
+ end
54
+
55
+ # Service worker targets registered in this context. Unlike {#workers},
56
+ # these are plain {Target}s and are not connected to. Attaching to a
57
+ # service worker's session keeps it alive indefinitely, so we only do
58
+ # that on demand, via `target.worker`.
59
+ #
60
+ # @return [Array<Target>]
61
+ def service_workers
62
+ @targets.values.select(&:service_worker?)
29
63
  end
30
64
 
31
65
  # When we call `page` method on target it triggers ruby to connect to given
@@ -40,23 +74,45 @@ module Ferrum
40
74
  windows.map(&:page)
41
75
  end
42
76
 
77
+ # Creates a new target in this context and eagerly connects to it as a
78
+ # {Page}, forwarding `options` to {Target#build_page}.
79
+ #
80
+ # @param [Hash] options
81
+ #
82
+ # @return [Page]
43
83
  def create_page(**options)
44
84
  target = create_target
45
85
  target.page = target.build_page(**options)
46
86
  end
47
87
 
88
+ # Creates a new target in this context via `Target.createTarget` and
89
+ # blocks until it's been registered (see {#add_target}).
90
+ #
91
+ # @return [Target]
92
+ #
93
+ # @raise [NoSuchTargetError]
48
94
  def create_target
49
95
  target_id = @client.command("Target.createTarget", browserContextId: @id, url: "about:blank")["targetId"]
50
96
 
51
97
  new_pending = Concurrent::IVar.new
52
98
  pending = @pendings.put_if_absent(target_id, new_pending) || new_pending
53
- resolved = pending.value(@client.timeout)
99
+ resolved = pending.value(@client.protocol_timeout)
54
100
  raise NoSuchTargetError unless resolved
55
101
 
56
102
  @pendings.delete(target_id)
57
103
  @targets[target_id]
58
104
  end
59
105
 
106
+ # Registers a target discovered via a CDP `Target.*` event, or updates
107
+ # the session id on one already known. Called by {Contexts} as targets
108
+ # are created/attached.
109
+ #
110
+ # @param [Hash] params
111
+ # The target's `targetInfo`.
112
+ #
113
+ # @param [String, nil] session_id
114
+ #
115
+ # @return [Target]
60
116
  def add_target(params:, session_id: nil)
61
117
  new_target = Target.new(@client, session_id, params)
62
118
  # `put_if_absent` returns nil if added a new value or existing if there was one already
@@ -68,48 +124,85 @@ module Ferrum
68
124
  new_pending = Concurrent::IVar.new
69
125
  pending = @pendings.put_if_absent(target.id, new_pending) || new_pending
70
126
  pending.try_set(true)
71
- true
127
+ target
72
128
  end
73
129
 
130
+ # Updates a known target's params, e.g. on `Target.targetInfoChanged`.
131
+ #
132
+ # @param [String] target_id
133
+ #
134
+ # @param [Hash] params
135
+ #
136
+ # @return [void]
74
137
  def update_target(target_id, params)
75
138
  @targets[target_id]&.update(params)
76
139
  end
77
140
 
141
+ # Removes a target, e.g. on `Target.targetDestroyed`/`targetCrashed`.
142
+ #
143
+ # @param [String] target_id
144
+ #
145
+ # @return [Target, nil]
78
146
  def delete_target(target_id)
79
147
  @targets.delete(target_id)
80
148
  end
81
149
 
150
+ # Manually attaches to a target, e.g. a service worker discovered via
151
+ # {#service_workers}. Once attached, `target.worker`/`target.page`
152
+ # returns a connected {Worker}/{Page} for it.
153
+ #
154
+ # Note: attaching to a service worker's session prevents Chrome from
155
+ # ever terminating it while the connection is open.
82
156
  def attach_target(target_id)
83
157
  target = @targets[target_id]
84
158
  raise NoSuchTargetError unless target
85
159
 
160
+ @contexts.manually_attached(target_id)
86
161
  session = @client.command("Target.attachToTarget", targetId: target_id, flatten: true)
87
162
  target.session_id = session["sessionId"]
88
163
  true
89
164
  end
90
165
 
166
+ # Returns the first target for which the block returns truthy.
167
+ #
168
+ # @return [Target, nil]
91
169
  def find_target
92
170
  @targets.each_value { |t| return t if yield(t) }
93
171
 
94
172
  nil
95
173
  end
96
174
 
175
+ # Closes the WebSocket connection of every connected target, without
176
+ # disposing the targets themselves.
177
+ #
178
+ # @return [void]
97
179
  def close_targets_connection
98
180
  @targets.each_value do |target|
99
181
  next unless target.connected?
100
182
 
101
- target.page.close_connection
183
+ target.close_connection
102
184
  end
103
185
  end
104
186
 
187
+ # Disposes this browser context and all of its targets.
188
+ #
189
+ # @return [Boolean]
105
190
  def dispose
106
191
  @contexts.dispose(@id)
107
192
  end
108
193
 
194
+ # Whether a target with the given id is known in this context.
195
+ #
196
+ # @param [String] target_id
197
+ #
198
+ # @return [Boolean]
109
199
  def target?(target_id)
110
200
  !!@targets[target_id]
111
201
  end
112
202
 
203
+ # Debug representation of the context, including its known targets.
204
+ #
205
+ # @return [String]
113
206
  def inspect
114
207
  %(#<#{self.class} @id=#{@id.inspect} @targets=#{@targets.inspect} @default_target=#{@default_target.inspect}>)
115
208
  end