ferrum 0.17.2 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/ferrum/accessibility/ax_node.rb +108 -0
  4. data/lib/ferrum/accessibility.rb +106 -0
  5. data/lib/ferrum/browser/binary.rb +42 -2
  6. data/lib/ferrum/browser/command.rb +20 -2
  7. data/lib/ferrum/browser/options/base.rb +66 -0
  8. data/lib/ferrum/browser/options/chrome.rb +35 -2
  9. data/lib/ferrum/browser/options.rb +41 -2
  10. data/lib/ferrum/browser/process/killer.rb +188 -0
  11. data/lib/ferrum/browser/process.rb +128 -72
  12. data/lib/ferrum/browser/xvfb.rb +24 -0
  13. data/lib/ferrum/browser.rb +47 -17
  14. data/lib/ferrum/client/subscriber.rb +65 -2
  15. data/lib/ferrum/client/web_socket.rb +126 -13
  16. data/lib/ferrum/client.rb +225 -12
  17. data/lib/ferrum/context.rb +113 -7
  18. data/lib/ferrum/contexts.rb +188 -19
  19. data/lib/ferrum/cookies/cookie.rb +6 -0
  20. data/lib/ferrum/cookies.rb +5 -0
  21. data/lib/ferrum/dialog.rb +18 -2
  22. data/lib/ferrum/downloads.rb +54 -1
  23. data/lib/ferrum/errors.rb +103 -4
  24. data/lib/ferrum/frame/dom.rb +65 -0
  25. data/lib/ferrum/frame/runtime.rb +57 -15
  26. data/lib/ferrum/frame.rb +58 -1
  27. data/lib/ferrum/headers.rb +6 -0
  28. data/lib/ferrum/interceptable.rb +83 -0
  29. data/lib/ferrum/keyboard.rb +25 -0
  30. data/lib/ferrum/mouse.rb +6 -0
  31. data/lib/ferrum/network/auth_request.rb +81 -2
  32. data/lib/ferrum/network/error.rb +15 -0
  33. data/lib/ferrum/network/exchange.rb +10 -0
  34. data/lib/ferrum/network/intercepted_request.rb +94 -2
  35. data/lib/ferrum/network/request.rb +1 -1
  36. data/lib/ferrum/network/response.rb +2 -0
  37. data/lib/ferrum/network.rb +121 -9
  38. data/lib/ferrum/node.rb +305 -15
  39. data/lib/ferrum/page/animation.rb +5 -0
  40. data/lib/ferrum/page/frames.rb +69 -7
  41. data/lib/ferrum/page/screencast.rb +5 -0
  42. data/lib/ferrum/page/screenshot.rb +55 -23
  43. data/lib/ferrum/page/stream.rb +71 -0
  44. data/lib/ferrum/page/tracing.rb +6 -0
  45. data/lib/ferrum/page.rb +148 -45
  46. data/lib/ferrum/proxy.rb +52 -2
  47. data/lib/ferrum/rgba.rb +10 -0
  48. data/lib/ferrum/target.rb +130 -3
  49. data/lib/ferrum/utils/attempt.rb +20 -0
  50. data/lib/ferrum/utils/elapsed_time.rb +38 -0
  51. data/lib/ferrum/utils/event.rb +14 -0
  52. data/lib/ferrum/utils/platform.rb +21 -0
  53. data/lib/ferrum/utils/thread.rb +15 -1
  54. data/lib/ferrum/version.rb +1 -1
  55. data/lib/ferrum/worker.rb +125 -0
  56. data/lib/ferrum.rb +7 -0
  57. metadata +6 -16
  58. data/lib/ferrum/browser/options/firefox.rb +0 -38
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 = "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
+ # 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 response. It's possible that this happened " \
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
 
@@ -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
  #
@@ -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
- errors = [NodeNotFoundError, NoExecutionContextError]
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
- # 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)
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
@@ -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
@@ -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