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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  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 +41 -0
  6. data/lib/ferrum/browser/command.rb +20 -0
  7. data/lib/ferrum/browser/options/base.rb +67 -0
  8. data/lib/ferrum/browser/options/chrome.rb +36 -1
  9. data/lib/ferrum/browser/options/firefox.rb +32 -0
  10. data/lib/ferrum/browser/options.rb +41 -2
  11. data/lib/ferrum/browser/process.rb +51 -0
  12. data/lib/ferrum/browser/xvfb.rb +24 -0
  13. data/lib/ferrum/browser.rb +32 -10
  14. data/lib/ferrum/client/subscriber.rb +58 -0
  15. data/lib/ferrum/client/web_socket.rb +62 -11
  16. data/lib/ferrum/client.rb +199 -8
  17. data/lib/ferrum/context.rb +97 -4
  18. data/lib/ferrum/contexts.rb +119 -9
  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 +52 -0
  23. data/lib/ferrum/errors.rb +58 -3
  24. data/lib/ferrum/frame/dom.rb +17 -0
  25. data/lib/ferrum/frame/runtime.rb +48 -7
  26. data/lib/ferrum/frame.rb +58 -1
  27. data/lib/ferrum/headers.rb +6 -0
  28. data/lib/ferrum/interceptable.rb +62 -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 +52 -20
  43. data/lib/ferrum/page/stream.rb +56 -0
  44. data/lib/ferrum/page/tracing.rb +6 -0
  45. data/lib/ferrum/page.rb +139 -42
  46. data/lib/ferrum/proxy.rb +52 -2
  47. data/lib/ferrum/rgba.rb +10 -0
  48. data/lib/ferrum/target.rb +124 -1
  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 +12 -0
  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
@@ -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 # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
76
- @client.on("Target.attachedToTarget") do |params|
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
- @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
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.page.client.session_id, params: info)
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
  #
@@ -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,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 = "Request to #{url} reached server, but there are still pending connections: #{pendings.join(', ')}"
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
 
@@ -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
@@ -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
- # We cannot store object_id in the node because page can be reloaded
156
- # and node destroyed so we need to retrieve it each time for given id.
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)