ferrum 0.17.2 → 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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  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 +42 -2
  6. data/lib/ferrum/browser/command.rb +20 -2
  7. data/lib/ferrum/browser/options/base.rb +66 -0
  8. data/lib/ferrum/browser/options/chrome.rb +35 -2
  9. data/lib/ferrum/browser/options.rb +41 -2
  10. data/lib/ferrum/browser/process/killer.rb +188 -0
  11. data/lib/ferrum/browser/process.rb +128 -72
  12. data/lib/ferrum/browser/xvfb.rb +24 -0
  13. data/lib/ferrum/browser.rb +47 -17
  14. data/lib/ferrum/client/subscriber.rb +65 -2
  15. data/lib/ferrum/client/web_socket.rb +126 -13
  16. data/lib/ferrum/client.rb +225 -12
  17. data/lib/ferrum/context.rb +113 -7
  18. data/lib/ferrum/contexts.rb +188 -19
  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 +54 -1
  23. data/lib/ferrum/errors.rb +103 -4
  24. data/lib/ferrum/frame/dom.rb +65 -0
  25. data/lib/ferrum/frame/runtime.rb +57 -15
  26. data/lib/ferrum/frame.rb +58 -1
  27. data/lib/ferrum/headers.rb +6 -0
  28. data/lib/ferrum/interceptable.rb +83 -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 +55 -23
  43. data/lib/ferrum/page/stream.rb +71 -0
  44. data/lib/ferrum/page/tracing.rb +6 -0
  45. data/lib/ferrum/page.rb +148 -45
  46. data/lib/ferrum/proxy.rb +52 -2
  47. data/lib/ferrum/rgba.rb +10 -0
  48. data/lib/ferrum/target.rb +130 -3
  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 +15 -1
  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
  58. data/lib/ferrum/browser/options/firefox.rb +0 -38
@@ -3,29 +3,74 @@
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
 
9
16
  attr_reader :id, :targets
10
17
 
11
- def initialize(client, contexts, id)
18
+ def initialize(client, contexts, id, implicit: false)
12
19
  @id = id
20
+ @implicit = implicit
13
21
  @client = client
14
22
  @contexts = contexts
15
23
  @targets = Concurrent::Map.new
16
24
  @pendings = Concurrent::Map.new
17
25
  end
18
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
+
37
+ # The context's first known target, creating one via
38
+ # `Target.createTarget` if none has attached yet.
39
+ #
40
+ # @return [Target]
19
41
  def default_target
20
42
  @default_target ||= create_target
21
43
  end
22
44
 
45
+ # Connects to and returns the {#default_target}'s page.
46
+ #
47
+ # @return [Page]
23
48
  def page
24
49
  default_target.page
25
50
  end
26
51
 
52
+ # All page targets in this context, connected to as {Page}s.
53
+ #
54
+ # @return [Array<Page>]
27
55
  def pages
28
- @targets.values.reject(&:iframe?).map(&:page)
56
+ @targets.values.select(&:page?).map(&:page)
57
+ end
58
+
59
+ # Dedicated and shared workers spawned by any page in this context.
60
+ #
61
+ # @return [Array<Worker>]
62
+ def workers
63
+ @targets.values.select { |t| t.worker? || t.shared_worker? }.map(&:worker)
64
+ end
65
+
66
+ # Service worker targets registered in this context. Unlike {#workers},
67
+ # these are plain {Target}s and are not connected to. Attaching to a
68
+ # service worker's session keeps it alive indefinitely, so we only do
69
+ # that on demand, via `target.worker`.
70
+ #
71
+ # @return [Array<Target>]
72
+ def service_workers
73
+ @targets.values.select(&:service_worker?)
29
74
  end
30
75
 
31
76
  # When we call `page` method on target it triggers ruby to connect to given
@@ -40,25 +85,48 @@ module Ferrum
40
85
  windows.map(&:page)
41
86
  end
42
87
 
88
+ # Creates a new target in this context and eagerly connects to it as a
89
+ # {Page}, forwarding `options` to {Target#build_page}.
90
+ #
91
+ # @param [Hash] options
92
+ #
93
+ # @return [Page]
43
94
  def create_page(**options)
44
95
  target = create_target
45
96
  target.page = target.build_page(**options)
46
97
  end
47
98
 
99
+ # Creates a new target in this context via `Target.createTarget` and
100
+ # blocks until it's been registered (see {#add_target}).
101
+ #
102
+ # @return [Target]
103
+ #
104
+ # @raise [NoSuchTargetError]
48
105
  def create_target
49
- 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"]
50
108
 
51
109
  new_pending = Concurrent::IVar.new
52
110
  pending = @pendings.put_if_absent(target_id, new_pending) || new_pending
53
- resolved = pending.value(@client.timeout)
111
+ resolved = pending.value(@client.protocol_timeout)
54
112
  raise NoSuchTargetError unless resolved
55
113
 
56
114
  @pendings.delete(target_id)
57
115
  @targets[target_id]
58
116
  end
59
117
 
118
+ # Registers a target discovered via a CDP `Target.*` event, or updates
119
+ # the session id on one already known. Called by {Contexts} as targets
120
+ # are created/attached.
121
+ #
122
+ # @param [Hash] params
123
+ # The target's `targetInfo`.
124
+ #
125
+ # @param [String, nil] session_id
126
+ #
127
+ # @return [Target]
60
128
  def add_target(params:, session_id: nil)
61
- new_target = Target.new(@client, session_id, params)
129
+ new_target = Target.new(@client, session_id, params, implicit: implicit?)
62
130
  # `put_if_absent` returns nil if added a new value or existing if there was one already
63
131
  target = @targets.put_if_absent(new_target.id, new_target) || new_target
64
132
  # on first iteration session_id may be nil, then if session is present here we must set it to the target
@@ -68,48 +136,86 @@ module Ferrum
68
136
  new_pending = Concurrent::IVar.new
69
137
  pending = @pendings.put_if_absent(target.id, new_pending) || new_pending
70
138
  pending.try_set(true)
71
- true
139
+ target
72
140
  end
73
141
 
142
+ # Updates a known target's params, e.g. on `Target.targetInfoChanged`.
143
+ #
144
+ # @param [String] target_id
145
+ #
146
+ # @param [Hash] params
147
+ #
148
+ # @return [void]
74
149
  def update_target(target_id, params)
75
150
  @targets[target_id]&.update(params)
76
151
  end
77
152
 
153
+ # Removes a target, e.g. on `Target.targetDestroyed`/`targetCrashed`.
154
+ #
155
+ # @param [String] target_id
156
+ #
157
+ # @return [Target, nil]
78
158
  def delete_target(target_id)
79
159
  @targets.delete(target_id)
80
160
  end
81
161
 
162
+ # Manually attaches to a target, e.g. a service worker discovered via
163
+ # {#service_workers}. Once attached, `target.worker`/`target.page`
164
+ # returns a connected {Worker}/{Page} for it.
165
+ #
166
+ # Note: attaching to a service worker's session prevents Chrome from
167
+ # ever terminating it while the connection is open.
82
168
  def attach_target(target_id)
83
169
  target = @targets[target_id]
84
170
  raise NoSuchTargetError unless target
85
171
 
172
+ @contexts.manually_attached(target_id)
86
173
  session = @client.command("Target.attachToTarget", targetId: target_id, flatten: true)
87
174
  target.session_id = session["sessionId"]
88
175
  true
89
176
  end
90
177
 
178
+ # Returns the first target for which the block returns truthy.
179
+ #
180
+ # @return [Target, nil]
91
181
  def find_target
92
182
  @targets.each_value { |t| return t if yield(t) }
93
183
 
94
184
  nil
95
185
  end
96
186
 
187
+ # Closes the WebSocket connection of every connected target, without
188
+ # disposing the targets themselves.
189
+ #
190
+ # @return [void]
97
191
  def close_targets_connection
98
192
  @targets.each_value do |target|
99
193
  next unless target.connected?
100
194
 
101
- target.page.close_connection
195
+ target.close_connection
102
196
  end
103
197
  end
104
198
 
199
+ # Disposes this browser context and all of its targets. The browser's
200
+ # implicit context cannot be disposed, see {#implicit?}.
201
+ #
202
+ # @return [Boolean, nil]
105
203
  def dispose
106
204
  @contexts.dispose(@id)
107
205
  end
108
206
 
207
+ # Whether a target with the given id is known in this context.
208
+ #
209
+ # @param [String] target_id
210
+ #
211
+ # @return [Boolean]
109
212
  def target?(target_id)
110
213
  !!@targets[target_id]
111
214
  end
112
215
 
216
+ # Debug representation of the context, including its known targets.
217
+ #
218
+ # @return [String]
113
219
  def inspect
114
220
  %(#<#{self.class} @id=#{@id.inspect} @targets=#{@targets.inspect} @default_target=#{@default_target.inspect}>)
115
221
  end
@@ -3,41 +3,82 @@
3
3
  require "ferrum/context"
4
4
 
5
5
  module Ferrum
6
+ #
7
+ # Owns the browser's whole collection of {Context}s, keyed by their
8
+ # `browserContextId`. Subscribes to the CDP `Target.*` events, creates
9
+ # {Context}s and {Target}s as they're discovered/attached, and routes
10
+ # target lifecycle updates (info changed, destroyed, crashed) to the
11
+ # {Context} they belong to.
12
+ #
6
13
  class Contexts
7
- ALLOWED_TARGET_TYPES = %w[page iframe].freeze
14
+ ALLOWED_TARGET_TYPES = %w[page iframe worker shared_worker service_worker].freeze
15
+ RECURSIVE_AUTO_ATTACH_TYPES = %w[page iframe worker shared_worker].freeze
8
16
 
9
17
  include Enumerable
10
18
 
11
19
  attr_reader :contexts
12
20
 
13
21
  def initialize(client)
14
- @contexts = Concurrent::Map.new
15
22
  @client = client
23
+ @contexts = Concurrent::Map.new
24
+ @manually_attached = Concurrent::Map.new
25
+ @default_context = find_implicit_context
16
26
  subscribe
17
27
  auto_attach
18
28
  discover
19
29
  end
20
30
 
31
+ # Marks a target, so the next time we see it attached, we leave its session alone instead of {#detach}ing it.
32
+ # Used by {Context#attach_target} right before it manually attaches to a service worker on the caller's behalf.
33
+ def manually_attached(target_id)
34
+ @manually_attached[target_id] = true
35
+ end
36
+
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.
40
+ #
41
+ # @return [Context]
21
42
  def default_context
22
43
  @default_context ||= create
23
44
  end
24
45
 
46
+ # Iterates over `[id, context]` pairs; returns an `Enumerator` if no
47
+ # block is given.
48
+ #
49
+ # @return [void, Enumerator]
25
50
  def each(&)
26
51
  return enum_for(__method__) unless block_given?
27
52
 
28
53
  @contexts.each(&)
29
54
  end
30
55
 
56
+ # The context with the given id, if any.
57
+ #
58
+ # @param [String] id
59
+ #
60
+ # @return [Context, nil]
31
61
  def [](id)
32
62
  @contexts[id]
33
63
  end
34
64
 
65
+ # The context that owns the given target, if any.
66
+ #
67
+ # @param [String] target_id
68
+ #
69
+ # @return [Context, nil]
35
70
  def find_by(target_id:)
36
71
  context = nil
37
72
  @contexts.each_value { |c| context = c if c.target?(target_id) }
38
73
  context
39
74
  end
40
75
 
76
+ # Creates a new browser context (like an incognito profile).
77
+ #
78
+ # @param [Hash] options
79
+ # Keyword arguments forwarded to `Target.createBrowserContext`.
80
+ #
81
+ # @return [Context]
41
82
  def create(**options)
42
83
  response = @client.command("Target.createBrowserContext", **options)
43
84
  context_id = response["browserContextId"]
@@ -46,46 +87,90 @@ module Ferrum
46
87
  context
47
88
  end
48
89
 
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.
96
+ #
97
+ # @param [String] context_id
98
+ #
99
+ # @return [Boolean, nil]
100
+ #
101
+ # @raise [TimeoutError, BrowserError]
49
102
  def dispose(context_id)
50
103
  context = @contexts[context_id]
51
104
  return unless context
105
+ return if context.implicit?
52
106
 
53
- context.close_targets_connection
54
- @client.command("Target.disposeBrowserContext", browserContextId: context.id)
55
- @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
56
113
  true
57
114
  end
58
115
 
116
+ # Closes the WebSocket connection of every target in every context,
117
+ # without disposing the contexts themselves.
118
+ #
119
+ # @return [void]
59
120
  def close_connections
60
121
  @contexts.each_value(&:close_targets_connection)
61
122
  end
62
123
 
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.
127
+ #
128
+ # @return [void]
129
+ #
130
+ # @raise [TimeoutError, BrowserError]
63
131
  def reset
132
+ error = nil
64
133
  context_ids = @client.command("Target.getBrowserContexts")["browserContextIds"]
65
134
  @default_context = nil if context_ids.include?(@default_context&.id)
66
- @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
67
141
  end
68
142
 
143
+ # Number of known contexts.
144
+ #
145
+ # @return [Integer]
69
146
  def size
70
147
  @contexts.size
71
148
  end
72
149
 
73
150
  private
74
151
 
75
- def subscribe # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
76
- @client.on("Target.attachedToTarget") do |params|
152
+ def subscribe
153
+ subscribe_attached_target(@client)
154
+ subscribe_target_created
155
+ end
156
+
157
+ # Registered once on the top-level client, and again on every page's/
158
+ # worker's own session once we re-arm auto-attach on it.
159
+ def subscribe_attached_target(client)
160
+ client.on("Target.attachedToTarget") do |params|
77
161
  info, session_id = params.values_at("targetInfo", "sessionId")
78
- next unless ALLOWED_TARGET_TYPES.include?(info["type"])
162
+ next detach_untracked(session_id) unless ALLOWED_TARGET_TYPES.include?(info["type"])
79
163
 
80
164
  context_id = info["browserContextId"]
81
165
  add_context(context_id)
166
+ target = @contexts[context_id]&.add_target(session_id: session_id, params: info)
82
167
 
83
- @contexts[context_id]&.add_target(session_id: session_id, params: info)
84
- if params["waitingForDebugger"]
85
- @client.session(session_id).command("Runtime.runIfWaitingForDebugger", async: true)
86
- end
168
+ rearm_auto_attach(session_id, info["type"])
169
+ handle_attach(target, session_id, params)
87
170
  end
171
+ end
88
172
 
173
+ def subscribe_target_created
89
174
  @client.on("Target.targetCreated") do |params|
90
175
  info = params["targetInfo"]
91
176
  next unless ALLOWED_TARGET_TYPES.include?(info["type"])
@@ -93,9 +178,8 @@ module Ferrum
93
178
  context_id = info["browserContextId"]
94
179
  add_context(context_id)
95
180
 
96
- if info["type"] == "iframe" &&
97
- (target = @contexts[context_id]&.find_target { |t| t.connected? && t.page.frame_by(id: info["targetId"]) })
98
- @contexts[context_id]&.add_target(session_id: target.page.client.session_id, params: info)
181
+ if info["type"] == "iframe" && (target = find_frame_owner(context_id, info["targetId"]))
182
+ @contexts[context_id]&.add_target(session_id: target.session_id, params: info)
99
183
  else
100
184
  @contexts[context_id]&.add_target(params: info)
101
185
  end
@@ -120,6 +204,71 @@ module Ferrum
120
204
  end
121
205
  end
122
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
+
213
+ def rearm_auto_attach(session_id, type)
214
+ return unless RECURSIVE_AUTO_ATTACH_TYPES.include?(type)
215
+
216
+ client = @client.session(session_id)
217
+ client.command("Target.setAutoAttach", autoAttach: true, waitForDebuggerOnStart: true, flatten: true, async: true)
218
+ subscribe_attached_target(client)
219
+ end
220
+
221
+ def handle_attach(target, session_id, params)
222
+ return unless target
223
+
224
+ if target.service_worker?
225
+ detach_unless_manually_attached(target, session_id)
226
+ elsif target.worker? || target.shared_worker?
227
+ connect_worker(target)
228
+ elsif params["waitingForDebugger"]
229
+ resume(session_id)
230
+ end
231
+ end
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
+
243
+ # Attaching keeps a service worker alive forever, so unless the caller
244
+ # explicitly asked to connect to it (via Context#attach_target), we
245
+ # just resume it and let go.
246
+ def detach_unless_manually_attached(target, session_id)
247
+ return if @manually_attached.delete(target.id)
248
+
249
+ detach(session_id)
250
+ rescue BrowserError, TimeoutError
251
+ nil
252
+ end
253
+
254
+ # Workers have no events to notify us when they're ready, so we
255
+ # connect right away. Worker#prepare enables the Network domain and
256
+ # only then resumes the debugger itself.
257
+ def connect_worker(target)
258
+ target.worker
259
+ rescue BrowserError, TimeoutError
260
+ nil
261
+ end
262
+
263
+ def resume(session_id)
264
+ @client.session(session_id).command("Runtime.runIfWaitingForDebugger", async: true)
265
+ end
266
+
267
+ def detach(session_id)
268
+ resume(session_id)
269
+ @client.command("Target.detachFromTarget", sessionId: session_id)
270
+ end
271
+
123
272
  def discover
124
273
  @client.command("Target.setDiscoverTargets", discover: true)
125
274
  end
@@ -131,9 +280,29 @@ module Ferrum
131
280
  def add_context(context_id)
132
281
  return if @contexts[context_id]
133
282
 
134
- context = Context.new(@client, self, context_id)
135
- @contexts[context_id] = context
136
- @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)
137
306
  end
138
307
  end
139
308
  end
@@ -122,6 +122,8 @@ module Ferrum
122
122
  @attributes["priority"]
123
123
  end
124
124
 
125
+ #
126
+ # Specifies whether the cookie is a first-party set cookie or not.
125
127
  #
126
128
  # @return [Boolean]
127
129
  #
@@ -131,6 +133,8 @@ module Ferrum
131
133
 
132
134
  alias same_party? sameparty?
133
135
 
136
+ #
137
+ # The scheme (`"Unset"`, `"NonSecure"`, `"Secure"`) that the cookie was set with.
134
138
  #
135
139
  # @return [String]
136
140
  #
@@ -138,6 +142,8 @@ module Ferrum
138
142
  @attributes["sourceScheme"]
139
143
  end
140
144
 
145
+ #
146
+ # The port that the cookie was set with, or `-1` if unknown.
141
147
  #
142
148
  # @return [Integer]
143
149
  #
@@ -4,6 +4,11 @@ require "yaml"
4
4
  require "ferrum/cookies/cookie"
5
5
 
6
6
  module Ferrum
7
+ #
8
+ # Manages a page's cookies via the CDP `Network` domain. Enumerable over
9
+ # the page's current {Cookies::Cookie}s, and provides methods to read,
10
+ # set, remove and clear them, as well as persist them to/from a YAML file.
11
+ #
7
12
  class Cookies
8
13
  include Enumerable
9
14
 
data/lib/ferrum/dialog.rb CHANGED
@@ -1,6 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ferrum
4
+ #
5
+ # Represents a JavaScript dialog (`alert`, `confirm`, `prompt` or
6
+ # `beforeunload`) shown by the page. Instances are yielded to blocks
7
+ # registered with `page.on(:dialog)`, and can be accepted (optionally with
8
+ # a prompt's text) or dismissed.
9
+ #
4
10
  class Dialog
5
11
  attr_reader :message, :default_prompt
6
12
 
@@ -51,8 +57,18 @@ module Ferrum
51
57
  @page.command("Page.handleJavaScriptDialog", slowmoable: true, accept: false)
52
58
  end
53
59
 
54
- def match?(regexp)
55
- !!message.match(regexp)
60
+ #
61
+ # Whether the dialog's message matches the given pattern.
62
+ #
63
+ # @param [Regexp, String] pattern
64
+ # A regexp matched against the message, or a string checked for inclusion in it.
65
+ #
66
+ # @return [Boolean]
67
+ #
68
+ def match?(pattern)
69
+ return message.match?(pattern) if pattern.is_a?(Regexp)
70
+
71
+ message.include?(pattern.to_s)
56
72
  end
57
73
  end
58
74
  end
@@ -1,6 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ferrum
4
+ #
5
+ # Tracks files downloaded by a page. Configures the browser's download
6
+ # behavior/destination via {#set_behavior}, subscribes to the
7
+ # `Browser.downloadWillBegin`/`Browser.downloadProgress` CDP events to
8
+ # record their progress in {#files}, and lets callers block until the
9
+ # current download finishes via {#wait}.
10
+ #
4
11
  class Downloads
5
12
  VALID_BEHAVIOR = %i[deny allow allowAndName default].freeze
6
13
 
@@ -10,10 +17,26 @@ module Ferrum
10
17
  @files = {}
11
18
  end
12
19
 
20
+ #
21
+ # Returns information about all downloaded files.
22
+ #
23
+ # @return [Array<Hash>]
24
+ #
13
25
  def files
14
26
  @files.values
15
27
  end
16
28
 
29
+ #
30
+ # Waits until the current download finishes.
31
+ #
32
+ # @param [Integer] timeout
33
+ # How long to wait in seconds.
34
+ #
35
+ # @yield
36
+ # Optional block that triggers the download, e.g. clicking a link.
37
+ #
38
+ # @return [void]
39
+ #
17
40
  def wait(timeout = 5)
18
41
  @event.reset
19
42
  yield if block_given?
@@ -21,22 +44,47 @@ module Ferrum
21
44
  @event.set
22
45
  end
23
46
 
47
+ #
48
+ # Sets the browser's download behavior and destination directory.
49
+ #
50
+ # @param [String] save_path
51
+ # Absolute path to the directory downloads should be saved to.
52
+ #
53
+ # @param [:deny, :allow, :allowAndName, :default] behavior
54
+ # Whether/how to allow downloads.
55
+ #
56
+ # @return [void]
57
+ #
58
+ # @raise [ArgumentError]
59
+ # @raise [Ferrum::Error]
60
+ #
24
61
  def set_behavior(save_path:, behavior: :allow)
25
62
  raise ArgumentError unless VALID_BEHAVIOR.include?(behavior.to_sym)
26
63
  raise Error, "supply absolute path for `:save_path` option" unless Pathname.new(save_path.to_s).absolute?
27
64
 
65
+ options = { browserContextId: @page.context_id } if @page.context_id
28
66
  @page.command("Browser.setDownloadBehavior",
29
- browserContextId: @page.context_id,
67
+ **Hash(options),
30
68
  downloadPath: save_path,
31
69
  behavior: behavior,
32
70
  eventsEnabled: true)
33
71
  end
34
72
 
73
+ #
74
+ # Subscribes to download related CDP events.
75
+ #
76
+ # @return [void]
77
+ #
35
78
  def subscribe
36
79
  subscribe_download_will_begin
37
80
  subscribe_download_progress
38
81
  end
39
82
 
83
+ #
84
+ # Subscribes to the `Browser.downloadWillBegin` event to track new downloads.
85
+ #
86
+ # @return [void]
87
+ #
40
88
  def subscribe_download_will_begin
41
89
  @page.on("Browser.downloadWillBegin") do |params|
42
90
  @event.reset
@@ -44,6 +92,11 @@ module Ferrum
44
92
  end
45
93
  end
46
94
 
95
+ #
96
+ # Subscribes to the `Browser.downloadProgress` event to track download state.
97
+ #
98
+ # @return [void]
99
+ #
47
100
  def subscribe_download_progress
48
101
  @page.on("Browser.downloadProgress") do |params|
49
102
  @files[params["guid"]].merge!(params)