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/errors.rb
CHANGED
|
@@ -1,47 +1,105 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Ferrum
|
|
4
|
+
# Base class for all errors raised by Ferrum.
|
|
4
5
|
class Error < StandardError; end
|
|
6
|
+
# Raised when the referenced page no longer exists.
|
|
5
7
|
class NoSuchPageError < Error; end
|
|
8
|
+
# Raised when the referenced browser target cannot be found.
|
|
6
9
|
class NoSuchTargetError < Error; end
|
|
10
|
+
# Raised when a requested feature isn't implemented for the current setup.
|
|
7
11
|
class NotImplementedError < Error; end
|
|
12
|
+
# Raised when no browser binary can be found at the configured path.
|
|
8
13
|
class BinaryNotFoundError < Error; end
|
|
14
|
+
# Raised when a required file path option is empty.
|
|
9
15
|
class EmptyPathError < Error; end
|
|
16
|
+
# Raised when the browser process server fails to start or crashes.
|
|
10
17
|
class ServerError < Error; end
|
|
11
18
|
|
|
19
|
+
# Raised when a request fails to reach the server, e.g. due to DNS or
|
|
20
|
+
# connectivity issues.
|
|
12
21
|
class StatusError < Error
|
|
13
22
|
def initialize(url, message = nil)
|
|
14
23
|
super(message || "Request to #{url} failed to reach server, check DNS and server status")
|
|
15
24
|
end
|
|
16
25
|
end
|
|
17
26
|
|
|
27
|
+
# Raised when the request reached the server, but the page still has
|
|
28
|
+
# pending network connections that never settled.
|
|
18
29
|
class PendingConnectionsError < StatusError
|
|
19
30
|
attr_reader :pendings
|
|
20
31
|
|
|
21
32
|
def initialize(url, pendings = [])
|
|
22
33
|
@pendings = pendings
|
|
23
|
-
|
|
24
|
-
message
|
|
34
|
+
message = "Request to #{url} reached server, but there are still pending connections"
|
|
35
|
+
message += ": #{pendings.join(', ')}" unless @pendings.empty?
|
|
25
36
|
|
|
26
37
|
super(url, message)
|
|
27
38
|
end
|
|
28
39
|
end
|
|
29
40
|
|
|
41
|
+
# Raised when waiting for a response from the browser times out.
|
|
30
42
|
class TimeoutError < Error
|
|
43
|
+
# The CDP method that didn't answer in time, `nil` when the wait wasn't for a command.
|
|
44
|
+
#
|
|
45
|
+
# @return [String, nil]
|
|
46
|
+
attr_reader :command
|
|
47
|
+
|
|
48
|
+
# The session the command was sent to, `nil` for the browser-wide session.
|
|
49
|
+
#
|
|
50
|
+
# @return [String, nil]
|
|
51
|
+
attr_reader :session_id
|
|
52
|
+
|
|
53
|
+
#
|
|
54
|
+
# @param [String, nil] command
|
|
55
|
+
# The CDP method that timed out.
|
|
56
|
+
#
|
|
57
|
+
# @param [String, nil] session_id
|
|
58
|
+
# The session it was sent to.
|
|
59
|
+
#
|
|
60
|
+
def initialize(command = nil, session_id: nil)
|
|
61
|
+
@command = command
|
|
62
|
+
@session_id = session_id
|
|
63
|
+
super()
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
#
|
|
67
|
+
# Explains that waiting for a response timed out, naming the command when
|
|
68
|
+
# there was one.
|
|
69
|
+
#
|
|
70
|
+
# @return [String]
|
|
71
|
+
#
|
|
31
72
|
def message
|
|
32
|
-
"Timed out waiting for
|
|
73
|
+
"Timed out waiting for #{awaited}. It's possible that this happened " \
|
|
33
74
|
"because something took a very long time (for example a page load " \
|
|
34
75
|
"was slow). If so, setting the :timeout option to a higher value might " \
|
|
35
|
-
"help."
|
|
76
|
+
"help. If this happened on an internal protocol call instead, try " \
|
|
77
|
+
"raising :protocol_timeout."
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def awaited
|
|
83
|
+
return "response" unless @command
|
|
84
|
+
|
|
85
|
+
@session_id ? "a response to #{@command} (session #{@session_id})" : "a response to #{@command}"
|
|
36
86
|
end
|
|
37
87
|
end
|
|
38
88
|
|
|
89
|
+
# Raised when an evaluated script takes too long to return a value.
|
|
39
90
|
class ScriptTimeoutError < Error
|
|
91
|
+
#
|
|
92
|
+
# Explains that the evaluated script timed out.
|
|
93
|
+
#
|
|
94
|
+
# @return [String]
|
|
95
|
+
#
|
|
40
96
|
def message
|
|
41
97
|
"Timed out waiting for evaluated script to return a value"
|
|
42
98
|
end
|
|
43
99
|
end
|
|
44
100
|
|
|
101
|
+
# Raised when the browser process doesn't produce a websocket URL within
|
|
102
|
+
# the configured `:process_timeout`.
|
|
45
103
|
class ProcessTimeoutError < Error
|
|
46
104
|
attr_reader :output
|
|
47
105
|
|
|
@@ -51,12 +109,28 @@ module Ferrum
|
|
|
51
109
|
end
|
|
52
110
|
end
|
|
53
111
|
|
|
112
|
+
# Raised when connecting to an already running browser, but its
|
|
113
|
+
# `/json/version` endpoint doesn't answer with the websocket url.
|
|
114
|
+
class NoWebSocketUrlError < Error
|
|
115
|
+
attr_reader :url
|
|
116
|
+
|
|
117
|
+
def initialize(url)
|
|
118
|
+
@url = url
|
|
119
|
+
super("Couldn't get a websocket url out of #{url}, `/json/version` there didn't answer with JSON. " \
|
|
120
|
+
"Check the browser is reachable at that url and that any credentials it needs are part of it")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Raised when trying to interact with a browser process that has died or
|
|
125
|
+
# a window that has already been closed.
|
|
54
126
|
class DeadBrowserError < Error
|
|
55
127
|
def initialize(message = "Browser is dead or given window is closed")
|
|
56
128
|
super
|
|
57
129
|
end
|
|
58
130
|
end
|
|
59
131
|
|
|
132
|
+
# Raised when a node keeps moving between attempts to interact with it,
|
|
133
|
+
# e.g. while trying to click it.
|
|
60
134
|
class NodeMovingError < Error
|
|
61
135
|
def initialize(node, prev, current)
|
|
62
136
|
@node = node
|
|
@@ -65,6 +139,11 @@ module Ferrum
|
|
|
65
139
|
super(message)
|
|
66
140
|
end
|
|
67
141
|
|
|
142
|
+
#
|
|
143
|
+
# Explains that the node moved between attempts to interact with it.
|
|
144
|
+
#
|
|
145
|
+
# @return [String]
|
|
146
|
+
#
|
|
68
147
|
def message
|
|
69
148
|
"#{@node.inspect} that you're trying to click is moving, hence " \
|
|
70
149
|
"we cannot. Previously it was at #{@prev.inspect} but now at " \
|
|
@@ -72,12 +151,15 @@ module Ferrum
|
|
|
72
151
|
end
|
|
73
152
|
end
|
|
74
153
|
|
|
154
|
+
# Raised when the content quads (coordinates) for a node cannot be computed.
|
|
75
155
|
class CoordinatesNotFoundError < Error
|
|
76
156
|
def initialize(message = "Could not compute content quads")
|
|
77
157
|
super
|
|
78
158
|
end
|
|
79
159
|
end
|
|
80
160
|
|
|
161
|
+
# Raised when the `:format` option passed to a screenshot call is not one
|
|
162
|
+
# of the supported formats.
|
|
81
163
|
class InvalidScreenshotFormatError < Error
|
|
82
164
|
def initialize(format)
|
|
83
165
|
valid_formats = Page::Screenshot::SUPPORTED_SCREENSHOT_FORMAT.join(" | ")
|
|
@@ -85,6 +167,8 @@ module Ferrum
|
|
|
85
167
|
end
|
|
86
168
|
end
|
|
87
169
|
|
|
170
|
+
# Raised when the browser returns an error response over CDP. Wraps the
|
|
171
|
+
# raw response so callers can inspect its code and data.
|
|
88
172
|
class BrowserError < Error
|
|
89
173
|
attr_reader :response
|
|
90
174
|
|
|
@@ -93,23 +177,38 @@ module Ferrum
|
|
|
93
177
|
super(response["message"])
|
|
94
178
|
end
|
|
95
179
|
|
|
180
|
+
#
|
|
181
|
+
# Error code from the raw CDP error response.
|
|
182
|
+
#
|
|
183
|
+
# @return [Integer, nil]
|
|
184
|
+
#
|
|
96
185
|
def code
|
|
97
186
|
response["code"]
|
|
98
187
|
end
|
|
99
188
|
|
|
189
|
+
#
|
|
190
|
+
# Additional data from the raw CDP error response.
|
|
191
|
+
#
|
|
192
|
+
# @return [Object, nil]
|
|
193
|
+
#
|
|
100
194
|
def data
|
|
101
195
|
response["data"]
|
|
102
196
|
end
|
|
103
197
|
end
|
|
104
198
|
|
|
199
|
+
# Raised when the browser reports that a DOM node no longer exists.
|
|
105
200
|
class NodeNotFoundError < BrowserError; end
|
|
106
201
|
|
|
202
|
+
# Raised when there's no JavaScript execution context available to run a
|
|
203
|
+
# command against, e.g. because the frame isn't attached.
|
|
107
204
|
class NoExecutionContextError < BrowserError
|
|
108
205
|
def initialize(response = nil)
|
|
109
206
|
super(response || { "message" => "There's no context available" })
|
|
110
207
|
end
|
|
111
208
|
end
|
|
112
209
|
|
|
210
|
+
# Raised when JavaScript evaluated in the page throws an exception. Carries
|
|
211
|
+
# the JS exception's class name, message, and stack trace.
|
|
113
212
|
class JavaScriptError < BrowserError
|
|
114
213
|
attr_reader :class_name, :message, :stack_trace
|
|
115
214
|
|
data/lib/ferrum/frame/dom.rb
CHANGED
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
# details (DOM.describeNode).
|
|
20
20
|
module Ferrum
|
|
21
21
|
class Frame
|
|
22
|
+
#
|
|
23
|
+
# Evaluates and executes JavaScript to query and manipulate a frame's
|
|
24
|
+
# DOM: reading the URL, title, doctype, and HTML, finding nodes by
|
|
25
|
+
# XPath or CSS selector, and injecting `<script>`, `<style>`, and
|
|
26
|
+
# `<link>` tags.
|
|
27
|
+
#
|
|
22
28
|
module DOM
|
|
23
29
|
SCRIPT_SRC_TAG = <<~JS
|
|
24
30
|
const script = document.createElement("script");
|
|
@@ -77,6 +83,17 @@ module Ferrum
|
|
|
77
83
|
evaluate("window.top.document.title")
|
|
78
84
|
end
|
|
79
85
|
|
|
86
|
+
#
|
|
87
|
+
# Returns current document's doctype declaration.
|
|
88
|
+
#
|
|
89
|
+
# @return [String, nil]
|
|
90
|
+
# The serialized `<!DOCTYPE ...>` declaration, or +nil+ if the
|
|
91
|
+
# document has none.
|
|
92
|
+
#
|
|
93
|
+
# @example
|
|
94
|
+
# browser.go_to("https://example.com")
|
|
95
|
+
# browser.doctype # => "<!DOCTYPE html>"
|
|
96
|
+
#
|
|
80
97
|
def doctype
|
|
81
98
|
evaluate("document.doctype && new XMLSerializer().serializeToString(document.doctype)")
|
|
82
99
|
end
|
|
@@ -226,6 +243,54 @@ module Ferrum
|
|
|
226
243
|
evaluate_func(expr, selector, within)
|
|
227
244
|
end
|
|
228
245
|
|
|
246
|
+
#
|
|
247
|
+
# Finds a node by using a CSS path or XPath selector, retrying until
|
|
248
|
+
# it appears or `timeout` elapses. Pass exactly one of `css`/`xpath`;
|
|
249
|
+
# each attempt delegates to {#at_css}/{#at_xpath} respectively, so it
|
|
250
|
+
# matches their behavior exactly.
|
|
251
|
+
#
|
|
252
|
+
# @param [String, nil] css
|
|
253
|
+
# The CSS path selector. Mutually exclusive with `xpath`.
|
|
254
|
+
#
|
|
255
|
+
# @param [String, nil] xpath
|
|
256
|
+
# The XPath selector. Mutually exclusive with `css`.
|
|
257
|
+
#
|
|
258
|
+
# @param [Node, nil] within
|
|
259
|
+
# The parent node to search within.
|
|
260
|
+
#
|
|
261
|
+
# @param [Float, Integer] timeout
|
|
262
|
+
# How long to keep retrying, in seconds.
|
|
263
|
+
#
|
|
264
|
+
# @param [Float, Integer] interval
|
|
265
|
+
# How long to sleep between attempts, in seconds.
|
|
266
|
+
#
|
|
267
|
+
# @return [Node, nil]
|
|
268
|
+
# The matching node, or `nil` if `timeout` elapses without a match.
|
|
269
|
+
#
|
|
270
|
+
# @raise [ArgumentError]
|
|
271
|
+
# If neither or both of `css`/`xpath` are given.
|
|
272
|
+
#
|
|
273
|
+
# @example
|
|
274
|
+
# browser.go_to("https://github.com/")
|
|
275
|
+
# browser.wait_for_selector(css: "a[aria-label='Issues you created']") # => Node
|
|
276
|
+
#
|
|
277
|
+
def wait_for_selector(css: nil, xpath: nil, within: nil, timeout: @page.timeout, interval: 0.05)
|
|
278
|
+
raise ArgumentError, "pass either `css:` or `xpath:`" if css && xpath
|
|
279
|
+
|
|
280
|
+
selector, method_name = css ? [css, :at_css] : [xpath, :at_xpath]
|
|
281
|
+
raise ArgumentError, "pass either `css:` or `xpath:`" unless selector
|
|
282
|
+
|
|
283
|
+
start = Utils::ElapsedTime.monotonic_time
|
|
284
|
+
|
|
285
|
+
until (node = send(method_name, selector, within: within))
|
|
286
|
+
return nil if Utils::ElapsedTime.timeout?(start, timeout)
|
|
287
|
+
|
|
288
|
+
sleep(interval)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
node
|
|
292
|
+
end
|
|
293
|
+
|
|
229
294
|
#
|
|
230
295
|
# Adds a `<script>` tag to the document.
|
|
231
296
|
#
|
data/lib/ferrum/frame/runtime.rb
CHANGED
|
@@ -3,15 +3,30 @@
|
|
|
3
3
|
require "singleton"
|
|
4
4
|
|
|
5
5
|
module Ferrum
|
|
6
|
+
#
|
|
7
|
+
# Placeholder object substituted for a JavaScript value that couldn't be
|
|
8
|
+
# fully serialized because it contains circular references. It exists
|
|
9
|
+
# only so that {#inspect} can report the situation instead of the
|
|
10
|
+
# evaluation raising or hanging.
|
|
11
|
+
#
|
|
6
12
|
class CyclicObject
|
|
7
13
|
include Singleton
|
|
8
14
|
|
|
15
|
+
# Debug representation of the singleton placeholder.
|
|
16
|
+
#
|
|
17
|
+
# @return [String]
|
|
9
18
|
def inspect
|
|
10
19
|
%(#<#{self.class} JavaScript object that cannot be represented in Ruby>)
|
|
11
20
|
end
|
|
12
21
|
end
|
|
13
22
|
|
|
14
23
|
class Frame
|
|
24
|
+
#
|
|
25
|
+
# Evaluates and executes JavaScript in a frame's execution context via
|
|
26
|
+
# `Runtime.callFunctionOn`, converting arguments and return values
|
|
27
|
+
# between Ruby and JS, and resolving object/array/node results
|
|
28
|
+
# (including cyclic ones, via {CyclicObject}) into Ruby equivalents.
|
|
29
|
+
#
|
|
15
30
|
module Runtime
|
|
16
31
|
INTERMITTENT_ATTEMPTS = ENV.fetch("FERRUM_INTERMITTENT_ATTEMPTS", 6).to_i
|
|
17
32
|
INTERMITTENT_SLEEP = ENV.fetch("FERRUM_INTERMITTENT_SLEEP", 0.1).to_f
|
|
@@ -86,10 +101,41 @@ module Ferrum
|
|
|
86
101
|
true
|
|
87
102
|
end
|
|
88
103
|
|
|
104
|
+
#
|
|
105
|
+
# Evaluates a raw JS function declaration (unlike {#evaluate}, which
|
|
106
|
+
# wraps the given expression in one), optionally on a specific remote
|
|
107
|
+
# object instead of the frame's global execution context.
|
|
108
|
+
#
|
|
109
|
+
# @param [String] expression
|
|
110
|
+
# A JS function declaration, e.g. `"function(a, b) { return a + b }"`.
|
|
111
|
+
#
|
|
112
|
+
# @param [Array] args
|
|
113
|
+
# Arguments to pass to the function.
|
|
114
|
+
#
|
|
115
|
+
# @param [Node, nil] on
|
|
116
|
+
# Remote object to invoke the function on.
|
|
117
|
+
#
|
|
89
118
|
def evaluate_func(expression, *args, on: nil)
|
|
90
119
|
call(expression: expression, arguments: args, on: on)
|
|
91
120
|
end
|
|
92
121
|
|
|
122
|
+
#
|
|
123
|
+
# Evaluates an expression against a given node's remote object (+this+
|
|
124
|
+
# refers to the node), returning the raw JS value rather than
|
|
125
|
+
# resolving it to a {Node}/Hash/Array.
|
|
126
|
+
#
|
|
127
|
+
# @param [Node] node
|
|
128
|
+
# The node to evaluate the expression on.
|
|
129
|
+
#
|
|
130
|
+
# @param [String] expression
|
|
131
|
+
# The JavaScript to evaluate.
|
|
132
|
+
#
|
|
133
|
+
# @param [Boolean] by_value
|
|
134
|
+
# Whether to return the plain JS value instead of a handle.
|
|
135
|
+
#
|
|
136
|
+
# @param [Integer] wait
|
|
137
|
+
# Passed through to the underlying `Runtime.callFunctionOn` command.
|
|
138
|
+
#
|
|
93
139
|
def evaluate_on(node:, expression:, by_value: true, wait: 0)
|
|
94
140
|
options = { handle: true }
|
|
95
141
|
expression = format("function() { return %s }", expression)
|
|
@@ -100,7 +146,8 @@ module Ferrum
|
|
|
100
146
|
private
|
|
101
147
|
|
|
102
148
|
def call(expression:, arguments: [], on: nil, wait: 0, handle: true, **options)
|
|
103
|
-
|
|
149
|
+
# do not rescue -> retry if we operate on an existing node
|
|
150
|
+
errors = on ? [] : [NodeNotFoundError, NoExecutionContextError]
|
|
104
151
|
|
|
105
152
|
Utils::Attempt.with_retry(errors: errors, max: INTERMITTENT_ATTEMPTS, wait: INTERMITTENT_SLEEP) do
|
|
106
153
|
params = options.dup
|
|
@@ -139,7 +186,7 @@ module Ferrum
|
|
|
139
186
|
end
|
|
140
187
|
end
|
|
141
188
|
|
|
142
|
-
def handle_response(response)
|
|
189
|
+
def handle_response(response, check_cyclic: true)
|
|
143
190
|
case response["type"]
|
|
144
191
|
when "boolean", "number", "string"
|
|
145
192
|
response["value"]
|
|
@@ -152,18 +199,13 @@ module Ferrum
|
|
|
152
199
|
|
|
153
200
|
case response["subtype"]
|
|
154
201
|
when "node"
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
# Though we can try to subscribe to `DOM.childNodeRemoved` and
|
|
158
|
-
# `DOM.childNodeInserted` in the future.
|
|
159
|
-
node_id = @page.command("DOM.requestNode", objectId: object_id)["nodeId"]
|
|
160
|
-
description = @page.command("DOM.describeNode", nodeId: node_id)["node"]
|
|
161
|
-
Node.new(self, @page.target_id, node_id, description)
|
|
202
|
+
description = @page.command("DOM.describeNode", objectId: object_id)["node"]
|
|
203
|
+
Node.new(self, @page.target_id, description, object_id: object_id)
|
|
162
204
|
when "array"
|
|
163
|
-
reduce_props(object_id, []) do |memo, key, value|
|
|
205
|
+
reduce_props(object_id, [], check_cyclic: check_cyclic) do |memo, key, value|
|
|
164
206
|
next(memo) unless Integer(key, exception: false)
|
|
165
207
|
|
|
166
|
-
value = value["objectId"] ? handle_response(value) : value["value"]
|
|
208
|
+
value = value["objectId"] ? handle_response(value, check_cyclic: false) : value["value"]
|
|
167
209
|
memo.insert(key.to_i, value)
|
|
168
210
|
end.compact
|
|
169
211
|
when "date"
|
|
@@ -171,8 +213,8 @@ module Ferrum
|
|
|
171
213
|
when "null"
|
|
172
214
|
nil
|
|
173
215
|
else
|
|
174
|
-
reduce_props(object_id, {}) do |memo, key, value|
|
|
175
|
-
value = value["objectId"] ? handle_response(value) : value["value"]
|
|
216
|
+
reduce_props(object_id, {}, check_cyclic: check_cyclic) do |memo, key, value|
|
|
217
|
+
value = value["objectId"] ? handle_response(value, check_cyclic: false) : value["value"]
|
|
176
218
|
memo.merge(key => value)
|
|
177
219
|
end
|
|
178
220
|
end
|
|
@@ -192,8 +234,8 @@ module Ferrum
|
|
|
192
234
|
end
|
|
193
235
|
end
|
|
194
236
|
|
|
195
|
-
def reduce_props(object_id, to)
|
|
196
|
-
if cyclic?(object_id).dig("result", "value")
|
|
237
|
+
def reduce_props(object_id, to, check_cyclic: true)
|
|
238
|
+
if check_cyclic && cyclic?(object_id).dig("result", "value")
|
|
197
239
|
to.is_a?(Array) ? [cyclic_object] : cyclic_object
|
|
198
240
|
else
|
|
199
241
|
props = @page.command("Runtime.getProperties", ownProperties: true, objectId: object_id)
|
data/lib/ferrum/frame.rb
CHANGED
|
@@ -4,6 +4,12 @@ require "ferrum/frame/dom"
|
|
|
4
4
|
require "ferrum/frame/runtime"
|
|
5
5
|
|
|
6
6
|
module Ferrum
|
|
7
|
+
#
|
|
8
|
+
# Represents a frame (the main document or a nested `iframe`) within a
|
|
9
|
+
# {Page}. Each frame has its own execution context for JavaScript and its
|
|
10
|
+
# own lifecycle state, tracked via {#state}. DOM search and JS evaluation
|
|
11
|
+
# methods are provided by the included {DOM} and {Runtime} modules.
|
|
12
|
+
#
|
|
7
13
|
class Frame
|
|
8
14
|
include DOM
|
|
9
15
|
include Runtime
|
|
@@ -12,6 +18,7 @@ module Ferrum
|
|
|
12
18
|
started_loading
|
|
13
19
|
navigated
|
|
14
20
|
stopped_loading
|
|
21
|
+
canceled
|
|
15
22
|
].freeze
|
|
16
23
|
|
|
17
24
|
# The Frame's unique id.
|
|
@@ -36,16 +43,36 @@ module Ferrum
|
|
|
36
43
|
|
|
37
44
|
# One of the states frame's in.
|
|
38
45
|
#
|
|
39
|
-
# @return [:started_loading, :navigated, :stopped_loading, nil]
|
|
46
|
+
# @return [:started_loading, :navigated, :stopped_loading, :canceled, nil]
|
|
40
47
|
attr_reader :state
|
|
41
48
|
|
|
49
|
+
# Frame loader id.
|
|
50
|
+
#
|
|
51
|
+
# @return [String, nil]
|
|
52
|
+
attr_accessor :loader_id
|
|
53
|
+
|
|
54
|
+
# Frame's lifecycle events (navigation, load, paint, etc.).
|
|
55
|
+
#
|
|
56
|
+
# @return [Array<Hash{String => (String|Float)}>]
|
|
57
|
+
attr_reader :lifecycle_events
|
|
58
|
+
|
|
42
59
|
def initialize(id, page, parent_id = nil)
|
|
43
60
|
@id = id
|
|
44
61
|
@page = page
|
|
45
62
|
@parent_id = parent_id
|
|
63
|
+
@lifecycle_events = []
|
|
46
64
|
@execution_id = Concurrent::MVar.new
|
|
47
65
|
end
|
|
48
66
|
|
|
67
|
+
# Sets the frame's state, validating it's one of {STATE_VALUES}.
|
|
68
|
+
#
|
|
69
|
+
# @param [Symbol] value
|
|
70
|
+
# One of `:started_loading`, `:navigated`, `:stopped_loading`,
|
|
71
|
+
# `:canceled`.
|
|
72
|
+
#
|
|
73
|
+
# @return [Symbol]
|
|
74
|
+
#
|
|
75
|
+
# @raise [ArgumentError]
|
|
49
76
|
def state=(value)
|
|
50
77
|
raise ArgumentError unless STATE_VALUES.include?(value)
|
|
51
78
|
|
|
@@ -94,6 +121,20 @@ module Ferrum
|
|
|
94
121
|
@parent_id.nil?
|
|
95
122
|
end
|
|
96
123
|
|
|
124
|
+
#
|
|
125
|
+
# Returns whether the frame has finished loading (+:stopped_loading+ state).
|
|
126
|
+
# Frames in +:canceled+ state (execution context torn down mid-navigation)
|
|
127
|
+
# are not considered idle.
|
|
128
|
+
#
|
|
129
|
+
# @return [Boolean]
|
|
130
|
+
#
|
|
131
|
+
# @example
|
|
132
|
+
# browser.go_to("https://example.com")
|
|
133
|
+
# browser.main_frame.idle? # => true
|
|
134
|
+
def idle?
|
|
135
|
+
state == :stopped_loading
|
|
136
|
+
end
|
|
137
|
+
|
|
97
138
|
#
|
|
98
139
|
# Returns the parent frame if this frame is nested in another one.
|
|
99
140
|
#
|
|
@@ -164,6 +205,15 @@ module Ferrum
|
|
|
164
205
|
value
|
|
165
206
|
end
|
|
166
207
|
|
|
208
|
+
#
|
|
209
|
+
# Sets the execution context id, or clears it if +nil+ is given (e.g.
|
|
210
|
+
# when the context is torn down mid-navigation and hasn't been
|
|
211
|
+
# replaced yet).
|
|
212
|
+
#
|
|
213
|
+
# @param [Integer, nil] value
|
|
214
|
+
#
|
|
215
|
+
# @return [Integer, nil]
|
|
216
|
+
#
|
|
167
217
|
def execution_id=(value)
|
|
168
218
|
if value.nil?
|
|
169
219
|
@execution_id.try_take!
|
|
@@ -172,11 +222,18 @@ module Ferrum
|
|
|
172
222
|
end
|
|
173
223
|
end
|
|
174
224
|
|
|
225
|
+
#
|
|
226
|
+
# Debug representation of the frame, including its internal state.
|
|
227
|
+
#
|
|
228
|
+
# @return [String]
|
|
229
|
+
#
|
|
175
230
|
def inspect
|
|
176
231
|
"#<#{self.class} " \
|
|
177
232
|
"@id=#{@id.inspect} " \
|
|
178
233
|
"@parent_id=#{@parent_id.inspect} " \
|
|
179
234
|
"@name=#{@name.inspect} " \
|
|
235
|
+
"@loader_id=#{@loader_id.inspect} " \
|
|
236
|
+
"@lifecycle_events=#{@lifecycle_events.inspect} " \
|
|
180
237
|
"@state=#{@state.inspect} " \
|
|
181
238
|
"@execution_id=#{@execution_id.inspect}>"
|
|
182
239
|
end
|
data/lib/ferrum/headers.rb
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Ferrum
|
|
4
|
+
#
|
|
5
|
+
# Manages the extra HTTP headers a page sends with its requests, via the
|
|
6
|
+
# CDP `Network.setExtraHTTPHeaders` command. Setting a `User-Agent` or
|
|
7
|
+
# `Accept-Language` header also updates the corresponding navigator
|
|
8
|
+
# override so JavaScript sees consistent values.
|
|
9
|
+
#
|
|
4
10
|
class Headers
|
|
5
11
|
def initialize(page)
|
|
6
12
|
@page = page
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ferrum
|
|
4
|
+
# Shared by {Page} and {Worker}: subscribes to the CDP events behind the
|
|
5
|
+
# `:request` (Fetch-domain request interception), `:response` (a request's
|
|
6
|
+
# response has fully loaded) and `:auth` (proxy/basic auth challenges)
|
|
7
|
+
# pseudo-events, on top of the includer's own `client` and `network`.
|
|
8
|
+
# Anything else is passed straight through to `client`.
|
|
9
|
+
module Interceptable
|
|
10
|
+
# Subscribes to a CDP event, or to `:request`/`:response`/`:auth`.
|
|
11
|
+
#
|
|
12
|
+
# @param [Symbol, String] name
|
|
13
|
+
#
|
|
14
|
+
# @yieldparam [Network::InterceptedRequest, Network::Exchange, Network::AuthRequest, Hash] arg
|
|
15
|
+
# For `:request`, the intercepted {Network::InterceptedRequest}. For
|
|
16
|
+
# `:response`, the finished {Network::Exchange}. For `:auth`, the
|
|
17
|
+
# {Network::AuthRequest} challenge. For any other (raw CDP) event name,
|
|
18
|
+
# the event's params `Hash`.
|
|
19
|
+
#
|
|
20
|
+
# @yieldparam [Integer] index
|
|
21
|
+
# This callback's position among all callbacks currently registered for
|
|
22
|
+
# the event.
|
|
23
|
+
#
|
|
24
|
+
# @yieldparam [Integer] total
|
|
25
|
+
# Total number of callbacks currently registered for the event.
|
|
26
|
+
#
|
|
27
|
+
# @return [Integer]
|
|
28
|
+
# The subscription id, used to unsubscribe via {#off}.
|
|
29
|
+
def on(name, &block)
|
|
30
|
+
case name
|
|
31
|
+
when :request
|
|
32
|
+
client.on("Fetch.requestPaused") do |params, index, total|
|
|
33
|
+
request = Network::InterceptedRequest.new(client, params)
|
|
34
|
+
exchange = network.find_or_build_exchange(request.network_id)
|
|
35
|
+
exchange.intercepted_request = request
|
|
36
|
+
block.call(request, index, total)
|
|
37
|
+
end
|
|
38
|
+
when :response
|
|
39
|
+
client.on("Network.loadingFinished") do |params, index, total|
|
|
40
|
+
exchange = network.select(params["requestId"]).last
|
|
41
|
+
block.call(exchange, index, total) if exchange
|
|
42
|
+
end
|
|
43
|
+
when :auth
|
|
44
|
+
client.on("Fetch.authRequired") do |params, index, total|
|
|
45
|
+
request = Network::AuthRequest.new(self, params)
|
|
46
|
+
block.call(request, index, total)
|
|
47
|
+
end
|
|
48
|
+
else
|
|
49
|
+
client.on(name, &block)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Unsubscribes a listener previously registered via {#on}.
|
|
54
|
+
#
|
|
55
|
+
# @param [Symbol, String] name
|
|
56
|
+
#
|
|
57
|
+
# @param [Integer] id
|
|
58
|
+
# The subscription id returned by {#on}.
|
|
59
|
+
#
|
|
60
|
+
# @return [void]
|
|
61
|
+
def off(name, id)
|
|
62
|
+
case name
|
|
63
|
+
when :request
|
|
64
|
+
client.off("Fetch.requestPaused", id)
|
|
65
|
+
when :response
|
|
66
|
+
client.off("Network.loadingFinished", id)
|
|
67
|
+
when :auth
|
|
68
|
+
client.off("Fetch.authRequired", id)
|
|
69
|
+
else
|
|
70
|
+
client.off(name, id)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Whether there's at least one callback registered for the event.
|
|
75
|
+
#
|
|
76
|
+
# @param [String] event
|
|
77
|
+
#
|
|
78
|
+
# @return [Boolean]
|
|
79
|
+
def subscribed?(event)
|
|
80
|
+
client.subscribed?(event)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/ferrum/keyboard.rb
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
module Ferrum
|
|
6
|
+
#
|
|
7
|
+
# Simulates keyboard input for a page via the CDP `Input.dispatchKeyEvent`
|
|
8
|
+
# command. Provides low-level `down`/`up` for individual keys as well as
|
|
9
|
+
# `type` for typing a sequence of characters/keys, translating key names
|
|
10
|
+
# and modifier chords into the CDP key event parameters Chrome expects.
|
|
11
|
+
#
|
|
6
12
|
class Keyboard
|
|
7
13
|
KEYS = JSON.parse(File.read(File.expand_path("keyboard.json", __dir__)))
|
|
8
14
|
MODIFIERS = { "alt" => 1, "ctrl" => 2, "control" => 2,
|
|
@@ -136,6 +142,13 @@ module Ferrum
|
|
|
136
142
|
modifiers: pressed.map { |k| MODIFIERS[k] }.reduce(0, :|)
|
|
137
143
|
)
|
|
138
144
|
|
|
145
|
+
# Synthetic key events alone do not trigger the browser's editing
|
|
146
|
+
# shortcuts (e.g. select-all). When the chord matches a platform
|
|
147
|
+
# accelerator, name the editing command explicitly so Chrome
|
|
148
|
+
# executes it via the CDP `commands` field.
|
|
149
|
+
command = editing_command(pressed, char)
|
|
150
|
+
key = key.merge(commands: [command]) if command
|
|
151
|
+
|
|
139
152
|
modifiers = pressed.map { |k| to_options(KEYS.fetch(KEYS_MAPPING[k.to_sym])) }
|
|
140
153
|
modifiers + [to_options(key)]
|
|
141
154
|
end.flatten
|
|
@@ -157,5 +170,17 @@ module Ferrum
|
|
|
157
170
|
def to_options(hash)
|
|
158
171
|
hash.inject({}) { |memo, (k, v)| memo.merge(k.to_sym => v) }
|
|
159
172
|
end
|
|
173
|
+
|
|
174
|
+
# Names the CDP editing command for a modifier chord, or nil if the chord
|
|
175
|
+
# is not an editing accelerator. The accelerator modifier is Cmd on macOS
|
|
176
|
+
# and Ctrl elsewhere, matching Chrome's own key bindings. Currently only
|
|
177
|
+
# select-all is mapped; extend the guard for further commands as needed.
|
|
178
|
+
def editing_command(pressed, char)
|
|
179
|
+
return unless char.casecmp?("a")
|
|
180
|
+
return if pressed.include?("shift")
|
|
181
|
+
|
|
182
|
+
accelerator = Utils::Platform.mac? ? MODIFIERS["meta"] : MODIFIERS["ctrl"]
|
|
183
|
+
"selectAll" if pressed.map { |k| MODIFIERS[k] }.compact.uniq == [accelerator]
|
|
184
|
+
end
|
|
160
185
|
end
|
|
161
186
|
end
|