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.
- checksums.yaml +4 -4
- data/README.md +4 -2
- data/lib/ferrum/accessibility/ax_node.rb +108 -0
- data/lib/ferrum/accessibility.rb +106 -0
- data/lib/ferrum/browser/binary.rb +42 -2
- data/lib/ferrum/browser/command.rb +20 -2
- data/lib/ferrum/browser/options/base.rb +66 -0
- data/lib/ferrum/browser/options/chrome.rb +35 -2
- data/lib/ferrum/browser/options.rb +41 -2
- data/lib/ferrum/browser/process/killer.rb +188 -0
- data/lib/ferrum/browser/process.rb +128 -72
- data/lib/ferrum/browser/xvfb.rb +24 -0
- data/lib/ferrum/browser.rb +47 -17
- data/lib/ferrum/client/subscriber.rb +65 -2
- data/lib/ferrum/client/web_socket.rb +126 -13
- data/lib/ferrum/client.rb +225 -12
- data/lib/ferrum/context.rb +113 -7
- data/lib/ferrum/contexts.rb +188 -19
- data/lib/ferrum/cookies/cookie.rb +6 -0
- data/lib/ferrum/cookies.rb +5 -0
- data/lib/ferrum/dialog.rb +18 -2
- data/lib/ferrum/downloads.rb +54 -1
- data/lib/ferrum/errors.rb +103 -4
- data/lib/ferrum/frame/dom.rb +65 -0
- data/lib/ferrum/frame/runtime.rb +57 -15
- data/lib/ferrum/frame.rb +58 -1
- data/lib/ferrum/headers.rb +6 -0
- data/lib/ferrum/interceptable.rb +83 -0
- data/lib/ferrum/keyboard.rb +25 -0
- data/lib/ferrum/mouse.rb +6 -0
- data/lib/ferrum/network/auth_request.rb +81 -2
- data/lib/ferrum/network/error.rb +15 -0
- data/lib/ferrum/network/exchange.rb +10 -0
- data/lib/ferrum/network/intercepted_request.rb +94 -2
- data/lib/ferrum/network/request.rb +1 -1
- data/lib/ferrum/network/response.rb +2 -0
- data/lib/ferrum/network.rb +121 -9
- data/lib/ferrum/node.rb +305 -15
- data/lib/ferrum/page/animation.rb +5 -0
- data/lib/ferrum/page/frames.rb +69 -7
- data/lib/ferrum/page/screencast.rb +5 -0
- data/lib/ferrum/page/screenshot.rb +55 -23
- data/lib/ferrum/page/stream.rb +71 -0
- data/lib/ferrum/page/tracing.rb +6 -0
- data/lib/ferrum/page.rb +148 -45
- data/lib/ferrum/proxy.rb +52 -2
- data/lib/ferrum/rgba.rb +10 -0
- data/lib/ferrum/target.rb +130 -3
- data/lib/ferrum/utils/attempt.rb +20 -0
- data/lib/ferrum/utils/elapsed_time.rb +38 -0
- data/lib/ferrum/utils/event.rb +14 -0
- data/lib/ferrum/utils/platform.rb +21 -0
- data/lib/ferrum/utils/thread.rb +15 -1
- data/lib/ferrum/version.rb +1 -1
- data/lib/ferrum/worker.rb +125 -0
- data/lib/ferrum.rb +7 -0
- metadata +6 -16
- data/lib/ferrum/browser/options/firefox.rb +0 -38
data/lib/ferrum/context.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
data/lib/ferrum/contexts.rb
CHANGED
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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.
|
|
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
|
|
76
|
-
@client
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
#
|
data/lib/ferrum/cookies.rb
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
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
|
data/lib/ferrum/downloads.rb
CHANGED
|
@@ -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
|
-
|
|
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)
|