ferrum 0.17.1 → 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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +9 -1369
  4. data/lib/ferrum/accessibility/ax_node.rb +108 -0
  5. data/lib/ferrum/accessibility.rb +106 -0
  6. data/lib/ferrum/browser/binary.rb +41 -0
  7. data/lib/ferrum/browser/command.rb +20 -0
  8. data/lib/ferrum/browser/options/base.rb +69 -2
  9. data/lib/ferrum/browser/options/chrome.rb +88 -26
  10. data/lib/ferrum/browser/options/firefox.rb +32 -0
  11. data/lib/ferrum/browser/options.rb +43 -3
  12. data/lib/ferrum/browser/process.rb +56 -4
  13. data/lib/ferrum/browser/xvfb.rb +24 -0
  14. data/lib/ferrum/browser.rb +35 -9
  15. data/lib/ferrum/client/subscriber.rb +58 -0
  16. data/lib/ferrum/client/web_socket.rb +63 -12
  17. data/lib/ferrum/client.rb +206 -14
  18. data/lib/ferrum/context.rb +99 -4
  19. data/lib/ferrum/contexts.rb +136 -22
  20. data/lib/ferrum/cookies/cookie.rb +7 -1
  21. data/lib/ferrum/cookies.rb +5 -0
  22. data/lib/ferrum/dialog.rb +18 -2
  23. data/lib/ferrum/downloads.rb +52 -0
  24. data/lib/ferrum/errors.rb +62 -6
  25. data/lib/ferrum/frame/dom.rb +17 -0
  26. data/lib/ferrum/frame/runtime.rb +49 -8
  27. data/lib/ferrum/frame.rb +58 -1
  28. data/lib/ferrum/headers.rb +6 -0
  29. data/lib/ferrum/interceptable.rb +62 -0
  30. data/lib/ferrum/keyboard.rb +25 -0
  31. data/lib/ferrum/mouse.rb +6 -0
  32. data/lib/ferrum/network/auth_request.rb +81 -2
  33. data/lib/ferrum/network/error.rb +15 -0
  34. data/lib/ferrum/network/exchange.rb +10 -0
  35. data/lib/ferrum/network/intercepted_request.rb +94 -2
  36. data/lib/ferrum/network/request.rb +1 -1
  37. data/lib/ferrum/network/response.rb +16 -1
  38. data/lib/ferrum/network.rb +122 -10
  39. data/lib/ferrum/node.rb +305 -15
  40. data/lib/ferrum/page/animation.rb +7 -2
  41. data/lib/ferrum/page/frames.rb +69 -7
  42. data/lib/ferrum/page/screencast.rb +5 -0
  43. data/lib/ferrum/page/screenshot.rb +52 -20
  44. data/lib/ferrum/page/stream.rb +56 -0
  45. data/lib/ferrum/page/tracing.rb +6 -0
  46. data/lib/ferrum/page.rb +141 -46
  47. data/lib/ferrum/proxy.rb +52 -2
  48. data/lib/ferrum/rgba.rb +10 -0
  49. data/lib/ferrum/target.rb +124 -1
  50. data/lib/ferrum/utils/attempt.rb +20 -0
  51. data/lib/ferrum/utils/elapsed_time.rb +38 -0
  52. data/lib/ferrum/utils/event.rb +14 -0
  53. data/lib/ferrum/utils/platform.rb +22 -1
  54. data/lib/ferrum/utils/thread.rb +12 -0
  55. data/lib/ferrum/version.rb +1 -1
  56. data/lib/ferrum/worker.rb +125 -0
  57. data/lib/ferrum.rb +7 -0
  58. metadata +8 -21
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
 
27
- def on(event, &block)
28
- @client.on(event_name(event), &block)
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
+ #
75
+ def on(event, &)
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
 
43
- def method_missing(name, *args, **opts, &block)
44
- @client.send(name, *args, **opts, &block)
113
+ #
114
+ # Delegates any method not defined on `SessionClient` to the underlying
115
+ # {Client}, e.g. `subscribed?`.
116
+ #
117
+ # @return [untyped]
118
+ #
119
+ def method_missing(name, ...)
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,14 +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
- delegate %i[timeout timeout=] => :options
152
+
153
+ delegate %i[protocol_timeout protocol_timeout=] => :options
65
154
 
66
155
  attr_reader :ws_url, :options, :subscriber
67
156
 
68
157
  def initialize(ws_url, options)
69
158
  @command_id = 0
159
+ @command_id_mutex = Mutex.new
70
160
  @ws_url = ws_url
71
161
  @options = options
72
162
  @pendings = Concurrent::Hash.new
@@ -76,12 +166,55 @@ module Ferrum
76
166
  start
77
167
  end
78
168
 
79
- 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)
80
189
  message = build_message(method, params)
81
- send_message(message, async: async)
190
+ send_message(message, async: async, timeout: timeout)
82
191
  end
83
192
 
84
- 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)
85
218
  if async
86
219
  @ws.send_message(message)
87
220
  true
@@ -89,7 +222,7 @@ module Ferrum
89
222
  pending = Concurrent::IVar.new
90
223
  @pendings[message[:id]] = pending
91
224
  @ws.send_message(message)
92
- data = pending.value!(timeout)
225
+ data = pending.value!(timeout || protocol_timeout)
93
226
  @pendings.delete(message[:id])
94
227
 
95
228
  raise DeadBrowserError if data.nil? && @ws.messages.closed?
@@ -101,22 +234,63 @@ module Ferrum
101
234
  end
102
235
  end
103
236
 
104
- def on(event, &block)
105
- @subscriber.on(event, &block)
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
+ #
246
+ def on(event, &)
247
+ @subscriber.on(event, &)
106
248
  end
107
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
+ #
108
261
  def off(event, id)
109
262
  @subscriber.off(event, id)
110
263
  end
111
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]
112
271
  def subscribed?(event)
113
272
  @subscriber.subscribed?(event)
114
273
  end
115
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
+ #
116
284
  def session(session_id)
117
285
  SessionClient.new(self, session_id)
118
286
  end
119
287
 
288
+ #
289
+ # Closes the underlying websocket, drops pending commands and stops
290
+ # the message-processing thread and subscriber.
291
+ #
292
+ # @return [void]
293
+ #
120
294
  def close
121
295
  @ws.close
122
296
  # Give a thread some time to handle a tail of messages
@@ -125,6 +299,11 @@ module Ferrum
125
299
  @subscriber.close
126
300
  end
127
301
 
302
+ #
303
+ # Custom inspection that exposes internal state useful for debugging.
304
+ #
305
+ # @return [String]
306
+ #
128
307
  def inspect
129
308
  "#<#{self.class} " \
130
309
  "@command_id=#{@command_id.inspect} " \
@@ -132,6 +311,17 @@ module Ferrum
132
311
  "@ws=#{@ws.inspect}>"
133
312
  end
134
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
+ #
135
325
  def build_message(method, params)
136
326
  { method: method, params: params }.merge(id: next_command_id)
137
327
  end
@@ -153,8 +343,10 @@ module Ferrum
153
343
  end
154
344
  end
155
345
 
346
+ # Locked so two concurrent commands never share an id and read each
347
+ # other's responses from @pendings.
156
348
  def next_command_id
157
- @command_id += 1
349
+ @command_id_mutex.synchronize { @command_id += 1 }
158
350
  end
159
351
 
160
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,74 +74,135 @@ 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
63
119
  target = @targets.put_if_absent(new_target.id, new_target) || new_target
120
+ # on first iteration session_id may be nil, then if session is present here we must set it to the target
121
+ target.session_id = session_id if session_id && target.session_id.nil?
64
122
  @default_target ||= target
65
123
 
66
124
  new_pending = Concurrent::IVar.new
67
125
  pending = @pendings.put_if_absent(target.id, new_pending) || new_pending
68
126
  pending.try_set(true)
69
- true
127
+ target
70
128
  end
71
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]
72
137
  def update_target(target_id, params)
73
138
  @targets[target_id]&.update(params)
74
139
  end
75
140
 
141
+ # Removes a target, e.g. on `Target.targetDestroyed`/`targetCrashed`.
142
+ #
143
+ # @param [String] target_id
144
+ #
145
+ # @return [Target, nil]
76
146
  def delete_target(target_id)
77
147
  @targets.delete(target_id)
78
148
  end
79
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.
80
156
  def attach_target(target_id)
81
157
  target = @targets[target_id]
82
158
  raise NoSuchTargetError unless target
83
159
 
160
+ @contexts.manually_attached(target_id)
84
161
  session = @client.command("Target.attachToTarget", targetId: target_id, flatten: true)
85
162
  target.session_id = session["sessionId"]
86
163
  true
87
164
  end
88
165
 
166
+ # Returns the first target for which the block returns truthy.
167
+ #
168
+ # @return [Target, nil]
89
169
  def find_target
90
170
  @targets.each_value { |t| return t if yield(t) }
91
171
 
92
172
  nil
93
173
  end
94
174
 
175
+ # Closes the WebSocket connection of every connected target, without
176
+ # disposing the targets themselves.
177
+ #
178
+ # @return [void]
95
179
  def close_targets_connection
96
180
  @targets.each_value do |target|
97
181
  next unless target.connected?
98
182
 
99
- target.page.close_connection
183
+ target.close_connection
100
184
  end
101
185
  end
102
186
 
187
+ # Disposes this browser context and all of its targets.
188
+ #
189
+ # @return [Boolean]
103
190
  def dispose
104
191
  @contexts.dispose(@id)
105
192
  end
106
193
 
194
+ # Whether a target with the given id is known in this context.
195
+ #
196
+ # @param [String] target_id
197
+ #
198
+ # @return [Boolean]
107
199
  def target?(target_id)
108
200
  !!@targets[target_id]
109
201
  end
110
202
 
203
+ # Debug representation of the context, including its known targets.
204
+ #
205
+ # @return [String]
111
206
  def inspect
112
207
  %(#<#{self.class} @id=#{@id.inspect} @targets=#{@targets.inspect} @default_target=#{@default_target.inspect}>)
113
208
  end