ferrum 0.17.2 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/ferrum/accessibility/ax_node.rb +108 -0
- data/lib/ferrum/accessibility.rb +106 -0
- data/lib/ferrum/browser/binary.rb +41 -0
- data/lib/ferrum/browser/command.rb +20 -0
- data/lib/ferrum/browser/options/base.rb +67 -0
- data/lib/ferrum/browser/options/chrome.rb +36 -1
- data/lib/ferrum/browser/options/firefox.rb +32 -0
- data/lib/ferrum/browser/options.rb +41 -2
- data/lib/ferrum/browser/process.rb +51 -0
- data/lib/ferrum/browser/xvfb.rb +24 -0
- data/lib/ferrum/browser.rb +32 -10
- data/lib/ferrum/client/subscriber.rb +58 -0
- data/lib/ferrum/client/web_socket.rb +62 -11
- data/lib/ferrum/client.rb +199 -8
- data/lib/ferrum/context.rb +97 -4
- data/lib/ferrum/contexts.rb +119 -9
- 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 +52 -0
- data/lib/ferrum/errors.rb +58 -3
- data/lib/ferrum/frame/dom.rb +17 -0
- data/lib/ferrum/frame/runtime.rb +48 -7
- data/lib/ferrum/frame.rb +58 -1
- data/lib/ferrum/headers.rb +6 -0
- data/lib/ferrum/interceptable.rb +62 -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 +52 -20
- data/lib/ferrum/page/stream.rb +56 -0
- data/lib/ferrum/page/tracing.rb +6 -0
- data/lib/ferrum/page.rb +139 -42
- data/lib/ferrum/proxy.rb +52 -2
- data/lib/ferrum/rgba.rb +10 -0
- data/lib/ferrum/target.rb +124 -1
- 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 +12 -0
- 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/contexts.rb
CHANGED
|
@@ -3,41 +3,79 @@
|
|
|
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
|
|
16
25
|
subscribe
|
|
17
26
|
auto_attach
|
|
18
27
|
discover
|
|
19
28
|
end
|
|
20
29
|
|
|
30
|
+
# Marks a target, so the next time we see it attached, we leave its session alone instead of {#detach}ing it.
|
|
31
|
+
# Used by {Context#attach_target} right before it manually attaches to a service worker on the caller's behalf.
|
|
32
|
+
def manually_attached(target_id)
|
|
33
|
+
@manually_attached[target_id] = true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The browser's first context, created lazily.
|
|
37
|
+
#
|
|
38
|
+
# @return [Context]
|
|
21
39
|
def default_context
|
|
22
40
|
@default_context ||= create
|
|
23
41
|
end
|
|
24
42
|
|
|
43
|
+
# Iterates over `[id, context]` pairs; returns an `Enumerator` if no
|
|
44
|
+
# block is given.
|
|
45
|
+
#
|
|
46
|
+
# @return [void, Enumerator]
|
|
25
47
|
def each(&)
|
|
26
48
|
return enum_for(__method__) unless block_given?
|
|
27
49
|
|
|
28
50
|
@contexts.each(&)
|
|
29
51
|
end
|
|
30
52
|
|
|
53
|
+
# The context with the given id, if any.
|
|
54
|
+
#
|
|
55
|
+
# @param [String] id
|
|
56
|
+
#
|
|
57
|
+
# @return [Context, nil]
|
|
31
58
|
def [](id)
|
|
32
59
|
@contexts[id]
|
|
33
60
|
end
|
|
34
61
|
|
|
62
|
+
# The context that owns the given target, if any.
|
|
63
|
+
#
|
|
64
|
+
# @param [String] target_id
|
|
65
|
+
#
|
|
66
|
+
# @return [Context, nil]
|
|
35
67
|
def find_by(target_id:)
|
|
36
68
|
context = nil
|
|
37
69
|
@contexts.each_value { |c| context = c if c.target?(target_id) }
|
|
38
70
|
context
|
|
39
71
|
end
|
|
40
72
|
|
|
73
|
+
# Creates a new browser context (like an incognito profile).
|
|
74
|
+
#
|
|
75
|
+
# @param [Hash] options
|
|
76
|
+
# Keyword arguments forwarded to `Target.createBrowserContext`.
|
|
77
|
+
#
|
|
78
|
+
# @return [Context]
|
|
41
79
|
def create(**options)
|
|
42
80
|
response = @client.command("Target.createBrowserContext", **options)
|
|
43
81
|
context_id = response["browserContextId"]
|
|
@@ -46,6 +84,11 @@ module Ferrum
|
|
|
46
84
|
context
|
|
47
85
|
end
|
|
48
86
|
|
|
87
|
+
# Disposes a browser context and all of its targets.
|
|
88
|
+
#
|
|
89
|
+
# @param [String] context_id
|
|
90
|
+
#
|
|
91
|
+
# @return [Boolean]
|
|
49
92
|
def dispose(context_id)
|
|
50
93
|
context = @contexts[context_id]
|
|
51
94
|
return unless context
|
|
@@ -56,36 +99,54 @@ module Ferrum
|
|
|
56
99
|
true
|
|
57
100
|
end
|
|
58
101
|
|
|
102
|
+
# Closes the WebSocket connection of every target in every context,
|
|
103
|
+
# without disposing the contexts themselves.
|
|
104
|
+
#
|
|
105
|
+
# @return [void]
|
|
59
106
|
def close_connections
|
|
60
107
|
@contexts.each_value(&:close_targets_connection)
|
|
61
108
|
end
|
|
62
109
|
|
|
110
|
+
# Disposes every context still known to the browser.
|
|
111
|
+
#
|
|
112
|
+
# @return [void]
|
|
63
113
|
def reset
|
|
64
114
|
context_ids = @client.command("Target.getBrowserContexts")["browserContextIds"]
|
|
65
115
|
@default_context = nil if context_ids.include?(@default_context&.id)
|
|
66
116
|
@contexts.each_key { |id| dispose(id) if context_ids.include?(id) }
|
|
67
117
|
end
|
|
68
118
|
|
|
119
|
+
# Number of known contexts.
|
|
120
|
+
#
|
|
121
|
+
# @return [Integer]
|
|
69
122
|
def size
|
|
70
123
|
@contexts.size
|
|
71
124
|
end
|
|
72
125
|
|
|
73
126
|
private
|
|
74
127
|
|
|
75
|
-
def subscribe
|
|
76
|
-
@client
|
|
128
|
+
def subscribe
|
|
129
|
+
subscribe_attached_target(@client)
|
|
130
|
+
subscribe_target_created
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Registered once on the top-level client, and again on every page's/
|
|
134
|
+
# worker's own session once we re-arm auto-attach on it.
|
|
135
|
+
def subscribe_attached_target(client)
|
|
136
|
+
client.on("Target.attachedToTarget") do |params|
|
|
77
137
|
info, session_id = params.values_at("targetInfo", "sessionId")
|
|
78
138
|
next unless ALLOWED_TARGET_TYPES.include?(info["type"])
|
|
79
139
|
|
|
80
140
|
context_id = info["browserContextId"]
|
|
81
141
|
add_context(context_id)
|
|
142
|
+
target = @contexts[context_id]&.add_target(session_id: session_id, params: info)
|
|
82
143
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
@client.session(session_id).command("Runtime.runIfWaitingForDebugger", async: true)
|
|
86
|
-
end
|
|
144
|
+
rearm_auto_attach(session_id, info["type"])
|
|
145
|
+
handle_attach(target, session_id, params)
|
|
87
146
|
end
|
|
147
|
+
end
|
|
88
148
|
|
|
149
|
+
def subscribe_target_created
|
|
89
150
|
@client.on("Target.targetCreated") do |params|
|
|
90
151
|
info = params["targetInfo"]
|
|
91
152
|
next unless ALLOWED_TARGET_TYPES.include?(info["type"])
|
|
@@ -95,7 +156,7 @@ module Ferrum
|
|
|
95
156
|
|
|
96
157
|
if info["type"] == "iframe" &&
|
|
97
158
|
(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.
|
|
159
|
+
@contexts[context_id]&.add_target(session_id: target.session_id, params: info)
|
|
99
160
|
else
|
|
100
161
|
@contexts[context_id]&.add_target(params: info)
|
|
101
162
|
end
|
|
@@ -120,6 +181,55 @@ module Ferrum
|
|
|
120
181
|
end
|
|
121
182
|
end
|
|
122
183
|
|
|
184
|
+
def rearm_auto_attach(session_id, type)
|
|
185
|
+
return unless RECURSIVE_AUTO_ATTACH_TYPES.include?(type)
|
|
186
|
+
|
|
187
|
+
client = @client.session(session_id)
|
|
188
|
+
client.command("Target.setAutoAttach", autoAttach: true, waitForDebuggerOnStart: true, flatten: true, async: true)
|
|
189
|
+
subscribe_attached_target(client)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def handle_attach(target, session_id, params)
|
|
193
|
+
return unless target
|
|
194
|
+
|
|
195
|
+
if target.service_worker?
|
|
196
|
+
detach_unless_manually_attached(target, session_id)
|
|
197
|
+
elsif target.worker? || target.shared_worker?
|
|
198
|
+
connect_worker(target)
|
|
199
|
+
elsif params["waitingForDebugger"]
|
|
200
|
+
resume(session_id)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Attaching keeps a service worker alive forever, so unless the caller
|
|
205
|
+
# explicitly asked to connect to it (via Context#attach_target), we
|
|
206
|
+
# just resume it and let go.
|
|
207
|
+
def detach_unless_manually_attached(target, session_id)
|
|
208
|
+
return if @manually_attached.delete(target.id)
|
|
209
|
+
|
|
210
|
+
detach(session_id)
|
|
211
|
+
rescue BrowserError
|
|
212
|
+
nil
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Workers have no events to notify us when they're ready, so we
|
|
216
|
+
# connect right away. Worker#prepare enables the Network domain and
|
|
217
|
+
# only then resumes the debugger itself.
|
|
218
|
+
def connect_worker(target)
|
|
219
|
+
target.worker
|
|
220
|
+
rescue BrowserError
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def resume(session_id)
|
|
225
|
+
@client.session(session_id).command("Runtime.runIfWaitingForDebugger", async: true)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def detach(session_id)
|
|
229
|
+
resume(session_id)
|
|
230
|
+
@client.command("Target.detachFromTarget", sessionId: session_id)
|
|
231
|
+
end
|
|
232
|
+
|
|
123
233
|
def discover
|
|
124
234
|
@client.command("Target.setDiscoverTargets", discover: true)
|
|
125
235
|
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,6 +44,20 @@ 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?
|
|
@@ -32,11 +69,21 @@ module Ferrum
|
|
|
32
69
|
eventsEnabled: true)
|
|
33
70
|
end
|
|
34
71
|
|
|
72
|
+
#
|
|
73
|
+
# Subscribes to download related CDP events.
|
|
74
|
+
#
|
|
75
|
+
# @return [void]
|
|
76
|
+
#
|
|
35
77
|
def subscribe
|
|
36
78
|
subscribe_download_will_begin
|
|
37
79
|
subscribe_download_progress
|
|
38
80
|
end
|
|
39
81
|
|
|
82
|
+
#
|
|
83
|
+
# Subscribes to the `Browser.downloadWillBegin` event to track new downloads.
|
|
84
|
+
#
|
|
85
|
+
# @return [void]
|
|
86
|
+
#
|
|
40
87
|
def subscribe_download_will_begin
|
|
41
88
|
@page.on("Browser.downloadWillBegin") do |params|
|
|
42
89
|
@event.reset
|
|
@@ -44,6 +91,11 @@ module Ferrum
|
|
|
44
91
|
end
|
|
45
92
|
end
|
|
46
93
|
|
|
94
|
+
#
|
|
95
|
+
# Subscribes to the `Browser.downloadProgress` event to track download state.
|
|
96
|
+
#
|
|
97
|
+
# @return [void]
|
|
98
|
+
#
|
|
47
99
|
def subscribe_download_progress
|
|
48
100
|
@page.on("Browser.downloadProgress") do |params|
|
|
49
101
|
@files[params["guid"]].merge!(params)
|
data/lib/ferrum/errors.rb
CHANGED
|
@@ -1,47 +1,73 @@
|
|
|
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
|
+
#
|
|
44
|
+
# Explains that waiting for a response timed out.
|
|
45
|
+
#
|
|
46
|
+
# @return [String]
|
|
47
|
+
#
|
|
31
48
|
def message
|
|
32
49
|
"Timed out waiting for response. It's possible that this happened " \
|
|
33
50
|
"because something took a very long time (for example a page load " \
|
|
34
51
|
"was slow). If so, setting the :timeout option to a higher value might " \
|
|
35
|
-
"help."
|
|
52
|
+
"help. If this happened on an internal protocol call instead, try " \
|
|
53
|
+
"raising :protocol_timeout."
|
|
36
54
|
end
|
|
37
55
|
end
|
|
38
56
|
|
|
57
|
+
# Raised when an evaluated script takes too long to return a value.
|
|
39
58
|
class ScriptTimeoutError < Error
|
|
59
|
+
#
|
|
60
|
+
# Explains that the evaluated script timed out.
|
|
61
|
+
#
|
|
62
|
+
# @return [String]
|
|
63
|
+
#
|
|
40
64
|
def message
|
|
41
65
|
"Timed out waiting for evaluated script to return a value"
|
|
42
66
|
end
|
|
43
67
|
end
|
|
44
68
|
|
|
69
|
+
# Raised when the browser process doesn't produce a websocket URL within
|
|
70
|
+
# the configured `:process_timeout`.
|
|
45
71
|
class ProcessTimeoutError < Error
|
|
46
72
|
attr_reader :output
|
|
47
73
|
|
|
@@ -51,12 +77,16 @@ module Ferrum
|
|
|
51
77
|
end
|
|
52
78
|
end
|
|
53
79
|
|
|
80
|
+
# Raised when trying to interact with a browser process that has died or
|
|
81
|
+
# a window that has already been closed.
|
|
54
82
|
class DeadBrowserError < Error
|
|
55
83
|
def initialize(message = "Browser is dead or given window is closed")
|
|
56
84
|
super
|
|
57
85
|
end
|
|
58
86
|
end
|
|
59
87
|
|
|
88
|
+
# Raised when a node keeps moving between attempts to interact with it,
|
|
89
|
+
# e.g. while trying to click it.
|
|
60
90
|
class NodeMovingError < Error
|
|
61
91
|
def initialize(node, prev, current)
|
|
62
92
|
@node = node
|
|
@@ -65,6 +95,11 @@ module Ferrum
|
|
|
65
95
|
super(message)
|
|
66
96
|
end
|
|
67
97
|
|
|
98
|
+
#
|
|
99
|
+
# Explains that the node moved between attempts to interact with it.
|
|
100
|
+
#
|
|
101
|
+
# @return [String]
|
|
102
|
+
#
|
|
68
103
|
def message
|
|
69
104
|
"#{@node.inspect} that you're trying to click is moving, hence " \
|
|
70
105
|
"we cannot. Previously it was at #{@prev.inspect} but now at " \
|
|
@@ -72,12 +107,15 @@ module Ferrum
|
|
|
72
107
|
end
|
|
73
108
|
end
|
|
74
109
|
|
|
110
|
+
# Raised when the content quads (coordinates) for a node cannot be computed.
|
|
75
111
|
class CoordinatesNotFoundError < Error
|
|
76
112
|
def initialize(message = "Could not compute content quads")
|
|
77
113
|
super
|
|
78
114
|
end
|
|
79
115
|
end
|
|
80
116
|
|
|
117
|
+
# Raised when the `:format` option passed to a screenshot call is not one
|
|
118
|
+
# of the supported formats.
|
|
81
119
|
class InvalidScreenshotFormatError < Error
|
|
82
120
|
def initialize(format)
|
|
83
121
|
valid_formats = Page::Screenshot::SUPPORTED_SCREENSHOT_FORMAT.join(" | ")
|
|
@@ -85,6 +123,8 @@ module Ferrum
|
|
|
85
123
|
end
|
|
86
124
|
end
|
|
87
125
|
|
|
126
|
+
# Raised when the browser returns an error response over CDP. Wraps the
|
|
127
|
+
# raw response so callers can inspect its code and data.
|
|
88
128
|
class BrowserError < Error
|
|
89
129
|
attr_reader :response
|
|
90
130
|
|
|
@@ -93,23 +133,38 @@ module Ferrum
|
|
|
93
133
|
super(response["message"])
|
|
94
134
|
end
|
|
95
135
|
|
|
136
|
+
#
|
|
137
|
+
# Error code from the raw CDP error response.
|
|
138
|
+
#
|
|
139
|
+
# @return [Integer, nil]
|
|
140
|
+
#
|
|
96
141
|
def code
|
|
97
142
|
response["code"]
|
|
98
143
|
end
|
|
99
144
|
|
|
145
|
+
#
|
|
146
|
+
# Additional data from the raw CDP error response.
|
|
147
|
+
#
|
|
148
|
+
# @return [Object, nil]
|
|
149
|
+
#
|
|
100
150
|
def data
|
|
101
151
|
response["data"]
|
|
102
152
|
end
|
|
103
153
|
end
|
|
104
154
|
|
|
155
|
+
# Raised when the browser reports that a DOM node no longer exists.
|
|
105
156
|
class NodeNotFoundError < BrowserError; end
|
|
106
157
|
|
|
158
|
+
# Raised when there's no JavaScript execution context available to run a
|
|
159
|
+
# command against, e.g. because the frame isn't attached.
|
|
107
160
|
class NoExecutionContextError < BrowserError
|
|
108
161
|
def initialize(response = nil)
|
|
109
162
|
super(response || { "message" => "There's no context available" })
|
|
110
163
|
end
|
|
111
164
|
end
|
|
112
165
|
|
|
166
|
+
# Raised when JavaScript evaluated in the page throws an exception. Carries
|
|
167
|
+
# the JS exception's class name, message, and stack trace.
|
|
113
168
|
class JavaScriptError < BrowserError
|
|
114
169
|
attr_reader :class_name, :message, :stack_trace
|
|
115
170
|
|
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
|
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)
|
|
@@ -152,13 +198,8 @@ module Ferrum
|
|
|
152
198
|
|
|
153
199
|
case response["subtype"]
|
|
154
200
|
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)
|
|
201
|
+
description = @page.command("DOM.describeNode", objectId: object_id)["node"]
|
|
202
|
+
Node.new(self, @page.target_id, description, object_id: object_id)
|
|
162
203
|
when "array"
|
|
163
204
|
reduce_props(object_id, []) do |memo, key, value|
|
|
164
205
|
next(memo) unless Integer(key, exception: false)
|