ferrum 0.16 → 0.17

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa8c09869c0473b537bed621b05616d1b062bc8c639df527b5bb60cb3722e854
4
- data.tar.gz: f7277b98ba6ca90e33b92573c88926042296b3e33bb9acb54b2a6946294dba1d
3
+ metadata.gz: f586cea5a9987e2a53bfb860fb503d36fc9e2f4e16b7288dafe68b2e62f20ede
4
+ data.tar.gz: a33d17e091b0d7b506f0934dd19f056ab15c72891659cc227f03bccad8d90cdd
5
5
  SHA512:
6
- metadata.gz: fd16b7e9d221cfc0c51f35af1362b486cb877120abfb61a515eed7f0b11733bdbdb2b54e089ef12599fa5b040fa85dd56315ac756353463d417482781be40644
7
- data.tar.gz: 2f1db2e77922c37efbd83a4aada4df7870afa676e6777fe7e437646c5c05f3d9eeece819c6ff001d53ace2e7d20edf9d1316d7ebcbb843fa695c3697069555f8
6
+ metadata.gz: 031a4dcb48ba0d22c1070ab8e905881f07369e7c19b61f03ef747d4ff3813be097a8f8bc65f692f4ed186687ba1e4e83c9de4482939a963b2541364c0d745b16
7
+ data.tar.gz: 207975dde463249331095cada046454110ee80d8a4a42fdc7f51898ad514e0aa5395946e037abd5c5599a7fa2d570c279925555822d432c4470c6e955f37b85b
data/README.md CHANGED
@@ -34,6 +34,7 @@ based on Ferrum and Mechanize.
34
34
  * [Navigation](https://github.com/rubycdp/ferrum#navigation)
35
35
  * [Finders](https://github.com/rubycdp/ferrum#finders)
36
36
  * [Screenshots](https://github.com/rubycdp/ferrum#screenshots)
37
+ * [Screencast](https://github.com/rubycdp/ferrum#screencast)
37
38
  * [Network](https://github.com/rubycdp/ferrum#network)
38
39
  * [Downloads](https://github.com/rubycdp/ferrum#downloads)
39
40
  * [Proxy](https://github.com/rubycdp/ferrum#proxy)
@@ -136,7 +137,7 @@ browser.quit
136
137
  In docker as root you must pass the no-sandbox browser option:
137
138
 
138
139
  ```ruby
139
- Ferrum::Browser.new(browser_options: { 'no-sandbox': nil })
140
+ Ferrum::Browser.new(browser_options: { "no-sandbox": nil })
140
141
  ```
141
142
 
142
143
  It has also been reported that the Chrome process repeatedly crashes when running inside a Docker container on an M1 Mac preventing Ferrum from working. Ferrum should work as expected when deployed to a Docker container on a non-M1 Mac.
@@ -150,8 +151,8 @@ Ferrum::Browser.new(options)
150
151
  ```
151
152
 
152
153
  * options `Hash`
153
- * `:headless` (String | Boolean) - Set browser as headless or not, `true` by default. You can set `"new"` to support
154
- [new headless mode](https://developer.chrome.com/articles/new-headless/).
154
+ * `:headless` (Boolean) - Set browser as headless or not, `true` by default.
155
+ * `:incognito` (Boolean) - Create an incognito profile for the browser startup window, `true` by default.
155
156
  * `:xvfb` (Boolean) - Run browser in a virtual framebuffer, `false` by default.
156
157
  * `:flatten` (Boolean) - Use one websocket connection to the browser and all the pages in flatten mode.
157
158
  * `:window_size` (Array) - The dimensions of the browser window in which to
@@ -416,7 +417,7 @@ page.screenshot(path: "google.jpg") # => 30902
416
417
  # Save to Base64 the whole page not only viewport and reduce quality
417
418
  page.screenshot(full: true, quality: 60, encoding: :base64) # "iVBORw0KGgoAAAANSUhEUgAABAAAAAMACAYAAAC6uhUNAAAAAXNSR0IArs4c6Q...
418
419
  # Save on the disk with the selected element in PNG
419
- page.screenshot(path: "google.png", selector: 'textarea') # => 11340
420
+ page.screenshot(path: "google.png", selector: "textarea") # => 11340
420
421
  # Save to Base64 with an area of the page in PNG
421
422
  page.screenshot(path: "google.png", area: { x: 0, y: 0, width: 400, height: 300 }) # => 54239
422
423
  # Save with specific background color
@@ -458,6 +459,58 @@ page.go_to("https://google.com/")
458
459
  page.mhtml(path: "google.mhtml") # => 87742
459
460
  ```
460
461
 
462
+ ## Screencast
463
+
464
+ #### start_screencast(\*\*options) { |data, metadata, session_id| ... }
465
+
466
+ Starts sending frames to record screencast to the given block.
467
+
468
+ * options `Hash`
469
+ * :format `Symbol` `:jpeg` | `:png` The format the image should be returned in.
470
+ * :quality `Integer` The image quality. **Note:** 0-100 works for JPEG only.
471
+ * :max_width `Integer` Maximum screencast frame width.
472
+ * :max_height `Integer` Maximum screencast frame height.
473
+ * :every_nth_frame `Integer` Send every n-th frame.
474
+
475
+ * Block inputs:
476
+ * data `String` Base64-encoded compressed image.
477
+ * metadata `Hash` Screencast frame metadata.
478
+ * "offsetTop" `Integer` Top offset in DIP.
479
+ * "pageScaleFactor" `Integer` Page scale factor.
480
+ * "deviceWidth" `Integer` Device screen width in DIP.
481
+ * "deviceHeight" `Integer` Device screen height in DIP.
482
+ * "scrollOffsetX" `Integer` Position of horizontal scroll in CSS pixels.
483
+ * "scrollOffsetY" `Integer` Position of vertical scroll in CSS pixels.
484
+ * "timestamp" `Float` (optional) Frame swap timestamp in seconds since Unix epoch.
485
+ * session_id `Integer` Frame number.
486
+
487
+ ```ruby
488
+ require "base64"
489
+
490
+ page.go_to("https://apple.com/ipad")
491
+
492
+ page.start_screencast(format: :jpeg, quality: 75) do |data, metadata|
493
+ timestamp = (metadata["timestamp"] * 1000).to_i
494
+ File.binwrite("image_#{timestamp}.jpg", Base64.decode64(data))
495
+ end
496
+
497
+ sleep 10
498
+
499
+ page.stop_screencast
500
+ ```
501
+
502
+ > ### 📝 NOTE
503
+ >
504
+ > Chrome only sends new frames while page content is changing. For example, if
505
+ > there is an animation or a video on the page, Chrome sends frames at the rate
506
+ > requested. On the other hand, if the page is nothing but a wall of static text,
507
+ > Chrome sends frames while the page renders. Once Chrome has finished rendering
508
+ > the page, it sends no more frames until something changes (e.g., navigating to
509
+ > another location).
510
+
511
+ #### stop_screencast
512
+
513
+ Stops sending frames.
461
514
 
462
515
  ## Network
463
516
 
@@ -1085,9 +1138,17 @@ Frame's unique id.
1085
1138
 
1086
1139
  Parent frame id if this one is nested in another one.
1087
1140
 
1141
+ #### parent : `Frame | nil`
1142
+
1143
+ Parent frame if this one is nested in another one.
1144
+
1145
+ #### frame_element : `Node | nil`
1146
+
1147
+ Returns the element in which the window is embedded.
1148
+
1088
1149
  #### execution_id : `Integer`
1089
1150
 
1090
- Execution context id which is used by JS, each frame has it's own context in
1151
+ Execution context id which is used by JS, each frame has its own context in
1091
1152
  which JS evaluates.
1092
1153
 
1093
1154
  #### name : `String | nil`
@@ -1322,7 +1383,7 @@ Closes browser tabs opened by the `Browser` instance.
1322
1383
 
1323
1384
  ```ruby
1324
1385
  # connect to a long-running Chrome process
1325
- browser = Ferrum::Browser.new(url: 'http://localhost:9222')
1386
+ browser = Ferrum::Browser.new(url: "http://localhost:9222")
1326
1387
 
1327
1388
  browser.go_to("https://github.com/")
1328
1389
 
@@ -39,10 +39,6 @@ module Ferrum
39
39
  !!options.xvfb
40
40
  end
41
41
 
42
- def headless_new?
43
- @flags["headless"] == "new"
44
- end
45
-
46
42
  def to_a
47
43
  [path] + @flags.map { |k, v| v.nil? ? "--#{k}" : "--#{k}=#{v}" }
48
44
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "singleton"
4
+ require "open3"
4
5
 
5
6
  module Ferrum
6
7
  class Browser
@@ -12,6 +13,14 @@ module Ferrum
12
13
  instance
13
14
  end
14
15
 
16
+ # @return [String, nil]
17
+ def self.version
18
+ out, = Open3.capture2(instance.detect_path, "--version")
19
+ out.strip
20
+ rescue Errno::ENOENT
21
+ nil
22
+ end
23
+
15
24
  def to_h
16
25
  self.class::DEFAULT_OPTIONS
17
26
  end
@@ -38,8 +38,9 @@ module Ferrum
38
38
  "safebrowsing-disable-auto-update" => nil,
39
39
  "password-store" => "basic",
40
40
  "no-startup-window" => nil,
41
- "remote-allow-origins" => "*"
42
- # NOTE: --no-sandbox is not needed if you properly setup a user in the container.
41
+ "remote-allow-origins" => "*",
42
+ "disable-blink-features" => "AutomationControlled"
43
+ # NOTE: --no-sandbox is not needed if you properly set up a user in the container.
43
44
  # https://github.com/ebidel/lighthouse-ci/blob/master/builder/Dockerfile#L35-L40
44
45
  # "no-sandbox" => nil,
45
46
  }.freeze
@@ -75,15 +76,10 @@ module Ferrum
75
76
  end
76
77
 
77
78
  def merge_default(flags, options)
78
- defaults = case options.headless
79
- when false
80
- except("headless", "disable-gpu")
81
- when "new"
82
- except("headless").merge("headless" => "new")
83
- end
84
-
79
+ defaults = except("headless", "disable-gpu") if options.headless == false
85
80
  defaults ||= DEFAULT_OPTIONS
86
- # On Windows, the --disable-gpu flag is a temporary work around for a few bugs.
81
+ defaults.delete("no-startup-window") if options.incognito == false
82
+ # On Windows, the --disable-gpu flag is a temporary workaround for a few bugs.
87
83
  # See https://bugs.chromium.org/p/chromium/issues/detail?id=737678 for more information.
88
84
  defaults = defaults.merge("disable-gpu" => nil) if Utils::Platform.windows?
89
85
  # Use Metal on Apple Silicon
@@ -14,7 +14,7 @@ module Ferrum
14
14
  attr_reader :window_size, :logger, :ws_max_receive_size,
15
15
  :js_errors, :base_url, :slowmo, :pending_connection_errors,
16
16
  :url, :ws_url, :env, :process_timeout, :browser_name, :browser_path,
17
- :save_path, :proxy, :port, :host, :headless, :browser_options,
17
+ :save_path, :proxy, :port, :host, :headless, :incognito, :browser_options,
18
18
  :ignore_default_browser_options, :xvfb, :flatten
19
19
  attr_accessor :timeout, :default_user_agent
20
20
 
@@ -27,6 +27,7 @@ module Ferrum
27
27
  @window_size = @options.fetch(:window_size, WINDOW_SIZE)
28
28
  @js_errors = @options.fetch(:js_errors, false)
29
29
  @headless = @options.fetch(:headless, true)
30
+ @incognito = @options.fetch(:incognito, true)
30
31
  @flatten = @options.fetch(:flatten, true)
31
32
  @pending_connection_errors = @options.fetch(:pending_connection_errors, true)
32
33
  @process_timeout = @options.fetch(:process_timeout, PROCESS_TIMEOUT)
@@ -178,7 +178,7 @@ module Ferrum
178
178
 
179
179
  def close_io(*ios)
180
180
  ios.each do |io|
181
- io.close unless io.closed?
181
+ io.close if io && !io.closed?
182
182
  rescue IOError
183
183
  raise unless RUBY_ENGINE == "jruby"
184
184
  end
@@ -23,6 +23,7 @@ module Ferrum
23
23
  headers cookies network downloads
24
24
  mouse keyboard
25
25
  screenshot pdf mhtml viewport_size device_pixel_ratio
26
+ start_screencast stop_screencast
26
27
  frames frame_by main_frame
27
28
  evaluate evaluate_on evaluate_async execute evaluate_func
28
29
  add_script_tag add_style_tag bypass_csp
@@ -44,6 +45,9 @@ module Ferrum
44
45
  # @option options [Boolean] :headless (true)
45
46
  # Set browser as headless or not.
46
47
  #
48
+ # @option options [Boolean] :incognito (true)
49
+ # Create an incognito profile for the browser startup window.
50
+ #
47
51
  # @option options [Boolean] :xvfb (false)
48
52
  # Run browser in a virtual framebuffer.
49
53
  #
@@ -245,8 +249,22 @@ module Ferrum
245
249
  VersionInfo.new(command("Browser.getVersion"))
246
250
  end
247
251
 
248
- def headless_new?
249
- process&.command&.headless_new?
252
+ #
253
+ # Opens headless session in the browser devtools frontend.
254
+ #
255
+ # @return [void]
256
+ #
257
+ # @since 0.16
258
+ #
259
+ def debug(bind = nil)
260
+ ::Process.spawn(process.path, debug_url)
261
+
262
+ bind ||= binding
263
+ if bind.respond_to?(:pry)
264
+ Pry.start(bind)
265
+ else
266
+ bind.irb
267
+ end
250
268
  end
251
269
 
252
270
  private
@@ -266,5 +284,18 @@ module Ferrum
266
284
  raise
267
285
  end
268
286
  end
287
+
288
+ def debug_url
289
+ response = JSON.parse(Net::HTTP.get(URI(build_remote_debug_url(path: "/json"))))
290
+
291
+ devtools_frontend_path = response[0]&.[]("devtoolsFrontendUrl")
292
+ raise "Could not generate debug url for remote debugging session" unless devtools_frontend_path
293
+
294
+ build_remote_debug_url(path: devtools_frontend_path)
295
+ end
296
+
297
+ def build_remote_debug_url(path:)
298
+ "http://#{process.host}:#{process.port}#{path}"
299
+ end
269
300
  end
270
301
  end
@@ -8,7 +8,7 @@ module Ferrum
8
8
  def initialize
9
9
  @regular = Queue.new
10
10
  @priority = Queue.new
11
- @on = Concurrent::Hash.new { |h, k| h[k] = Concurrent::Array.new }
11
+ @on = Concurrent::Hash.new
12
12
 
13
13
  start
14
14
  end
@@ -22,7 +22,13 @@ module Ferrum
22
22
  end
23
23
 
24
24
  def on(event, &block)
25
+ @on[event] ||= Concurrent::Array.new
25
26
  @on[event] << block
27
+ @on[event].index(block)
28
+ end
29
+
30
+ def off(event, id)
31
+ @on[event].delete_at(id)
26
32
  true
27
33
  end
28
34
 
@@ -65,8 +71,8 @@ module Ferrum
65
71
  method, session_id, params = message.values_at("method", "sessionId", "params")
66
72
  event = SessionClient.event_name(method, session_id)
67
73
 
68
- total = @on[event].size
69
- @on[event].each_with_index do |block, index|
74
+ total = @on[event]&.size.to_i
75
+ @on[event]&.each_with_index do |block, index|
70
76
  # In case of multiple callbacks we provide current index and total
71
77
  block.call(params, index, total)
72
78
  end
data/lib/ferrum/client.rb CHANGED
@@ -28,6 +28,10 @@ module Ferrum
28
28
  @client.on(event_name(event), &block)
29
29
  end
30
30
 
31
+ def off(event, id)
32
+ @client.off(event_name(event), id)
33
+ end
34
+
31
35
  def subscribed?(event)
32
36
  @client.subscribed?(event_name(event))
33
37
  end
@@ -101,6 +105,10 @@ module Ferrum
101
105
  @subscriber.on(event, &block)
102
106
  end
103
107
 
108
+ def off(event, id)
109
+ @subscriber.off(event, id)
110
+ end
111
+
104
112
  def subscribed?(event)
105
113
  @subscriber.subscribed?(event)
106
114
  end
@@ -13,7 +13,7 @@ module Ferrum
13
13
  @client = client
14
14
  @contexts = contexts
15
15
  @targets = Concurrent::Map.new
16
- @pendings = Concurrent::MVar.new
16
+ @pendings = Concurrent::Map.new
17
17
  end
18
18
 
19
19
  def default_target
@@ -25,11 +25,11 @@ module Ferrum
25
25
  end
26
26
 
27
27
  def pages
28
- @targets.values.map(&:page)
28
+ @targets.values.reject(&:iframe?).map(&:page)
29
29
  end
30
30
 
31
31
  # When we call `page` method on target it triggers ruby to connect to given
32
- # page by WebSocket, if there are many opened windows but we need only one
32
+ # page by WebSocket, if there are many opened windows, but we need only one
33
33
  # it makes more sense to get and connect to the needed one only which
34
34
  # usually is the last one.
35
35
  def windows(pos = nil, size = 1)
@@ -46,19 +46,27 @@ module Ferrum
46
46
  end
47
47
 
48
48
  def create_target
49
- @client.command("Target.createTarget", browserContextId: @id, url: "about:blank")
50
- target = @pendings.take(@client.timeout)
51
- raise NoSuchTargetError unless target.is_a?(Target)
49
+ target_id = @client.command("Target.createTarget", browserContextId: @id, url: "about:blank")["targetId"]
52
50
 
53
- target
51
+ new_pending = Concurrent::IVar.new
52
+ pending = @pendings.put_if_absent(target_id, new_pending) || new_pending
53
+ resolved = pending.value(@client.timeout)
54
+ raise NoSuchTargetError unless resolved
55
+
56
+ @pendings.delete(target_id)
57
+ @targets[target_id]
54
58
  end
55
59
 
56
60
  def add_target(params:, session_id: nil)
57
61
  new_target = Target.new(@client, session_id, params)
58
- target = @targets.put_if_absent(new_target.id, new_target)
59
- target ||= new_target # `put_if_absent` returns nil if added a new value or existing if there was one already
60
- @pendings.put(target, @client.timeout) if @pendings.empty?
61
- target
62
+ # `put_if_absent` returns nil if added a new value or existing if there was one already
63
+ target = @targets.put_if_absent(new_target.id, new_target) || new_target
64
+ @default_target ||= target
65
+
66
+ new_pending = Concurrent::IVar.new
67
+ pending = @pendings.put_if_absent(target.id, new_pending) || new_pending
68
+ pending.try_set(true)
69
+ true
62
70
  end
63
71
 
64
72
  def update_target(target_id, params)
@@ -69,6 +77,21 @@ module Ferrum
69
77
  @targets.delete(target_id)
70
78
  end
71
79
 
80
+ def attach_target(target_id)
81
+ target = @targets[target_id]
82
+ raise NoSuchTargetError unless target
83
+
84
+ session = @client.command("Target.attachToTarget", targetId: target_id, flatten: true)
85
+ target.session_id = session["sessionId"]
86
+ true
87
+ end
88
+
89
+ def find_target
90
+ @targets.each_value { |t| return t if yield(t) }
91
+
92
+ nil
93
+ end
94
+
72
95
  def close_targets_connection
73
96
  @targets.each_value do |target|
74
97
  next unless target.connected?
@@ -4,6 +4,8 @@ require "ferrum/context"
4
4
 
5
5
  module Ferrum
6
6
  class Contexts
7
+ ALLOWED_TARGET_TYPES = %w[page iframe].freeze
8
+
7
9
  include Enumerable
8
10
 
9
11
  attr_reader :contexts
@@ -67,12 +69,19 @@ module Ferrum
67
69
 
68
70
  private
69
71
 
72
+ # rubocop:disable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
70
73
  def subscribe
71
74
  @client.on("Target.attachedToTarget") do |params|
72
75
  info, session_id = params.values_at("targetInfo", "sessionId")
73
- next unless info["type"] == "page"
76
+ next unless ALLOWED_TARGET_TYPES.include?(info["type"])
74
77
 
75
78
  context_id = info["browserContextId"]
79
+ unless @contexts[context_id]
80
+ context = Context.new(@client, self, context_id)
81
+ @contexts[context_id] = context
82
+ @default_context ||= context
83
+ end
84
+
76
85
  @contexts[context_id]&.add_target(session_id: session_id, params: info)
77
86
  if params["waitingForDebugger"]
78
87
  @client.session(session_id).command("Runtime.runIfWaitingForDebugger", async: true)
@@ -81,15 +90,21 @@ module Ferrum
81
90
 
82
91
  @client.on("Target.targetCreated") do |params|
83
92
  info = params["targetInfo"]
84
- next unless info["type"] == "page"
93
+ next unless ALLOWED_TARGET_TYPES.include?(info["type"])
85
94
 
86
95
  context_id = info["browserContextId"]
87
- @contexts[context_id]&.add_target(params: info)
96
+
97
+ if info["type"] == "iframe" &&
98
+ (target = @contexts[context_id].find_target { |t| t.connected? && t.page.frame_by(id: info["targetId"]) })
99
+ @contexts[context_id]&.add_target(session_id: target.page.client.session_id, params: info)
100
+ else
101
+ @contexts[context_id]&.add_target(params: info)
102
+ end
88
103
  end
89
104
 
90
105
  @client.on("Target.targetInfoChanged") do |params|
91
106
  info = params["targetInfo"]
92
- next unless info["type"] == "page"
107
+ next unless ALLOWED_TARGET_TYPES.include?(info["type"])
93
108
 
94
109
  context_id, target_id = info.values_at("browserContextId", "targetId")
95
110
  @contexts[context_id]&.update_target(target_id, info)
@@ -105,6 +120,7 @@ module Ferrum
105
120
  context&.delete_target(params["targetId"])
106
121
  end
107
122
  end
123
+ # rubocop:enable Metrics/PerceivedComplexity, Metrics/CyclomaticComplexity
108
124
 
109
125
  def discover
110
126
  @client.command("Target.setDiscoverTargets", discover: true)
@@ -14,7 +14,7 @@
14
14
  # any updates, for example, the node may be destroyed without any notification.
15
15
  # This is a way to keep a reference to the Node, when you don't necessarily want
16
16
  # to keep track of it. One example would be linking to the node from performance
17
- # data (e.g. relayout root node). BackendNodeId may be either resolved to
17
+ # data (e.g. re-layout root node). BackendNodeId may be either resolved to
18
18
  # inspected node (DOM.pushNodesByBackendIdsToFrontend) or described in more
19
19
  # details (DOM.describeNode).
20
20
  module Ferrum
@@ -92,7 +92,23 @@ module Ferrum
92
92
  # browser.body # => '<html itemscope="" itemtype="http://schema.org/WebPage" lang="ru"><head>...
93
93
  #
94
94
  def body
95
- evaluate("document.documentElement.outerHTML")
95
+ evaluate("document.documentElement?.outerHTML") || ""
96
+ end
97
+
98
+ #
99
+ # Returns the element in which the window is embedded.
100
+ #
101
+ # @return [Node, nil]
102
+ # The element in which the window is embedded.
103
+ #
104
+ # @example
105
+ # browser.go_to("https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe")
106
+ # frame = browser.frames.last
107
+ # frame.frame_element # => [Node]
108
+ # frame.parent.parent.parent.frame_element # => nil
109
+ #
110
+ def frame_element
111
+ evaluate("window.frameElement")
96
112
  end
97
113
 
98
114
  #
data/lib/ferrum/frame.rb CHANGED
@@ -94,6 +94,23 @@ module Ferrum
94
94
  @parent_id.nil?
95
95
  end
96
96
 
97
+ #
98
+ # Returns the parent frame if this frame is nested in another one.
99
+ #
100
+ # @return [Frame, nil]
101
+ #
102
+ # @example
103
+ # browser.go_to("https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe")
104
+ # frame = browser.frames.last
105
+ # frame.url # => "https://www.openstreetmap.org/export/embed.html?bbox=-0.004017949104309083%2C51.47612752641776%2C0.00030577182769775396%2C51.478569861898606&layer=mapnik"
106
+ # frame.parent.main? # => false
107
+ # frame.parent.parent.main? # => false
108
+ # frame.parent.parent.parent.main? # => true
109
+ #
110
+ def parent
111
+ @page.frame_by(id: @parent_id) if @parent_id
112
+ end
113
+
97
114
  #
98
115
  # Sets a content of a given frame.
99
116
  #
@@ -93,7 +93,6 @@ module Ferrum
93
93
 
94
94
  private
95
95
 
96
- # TODO: Refactor it, and try to simplify complexity
97
96
  # rubocop:disable Metrics/PerceivedComplexity
98
97
  # rubocop:disable Metrics/CyclomaticComplexity
99
98
  def normalize_keys(keys, pressed_keys = [], memo = [])
data/lib/ferrum/mouse.rb CHANGED
@@ -3,11 +3,36 @@
3
3
  module Ferrum
4
4
  class Mouse
5
5
  CLICK_WAIT = ENV.fetch("FERRUM_CLICK_WAIT", 0.1).to_f
6
- VALID_BUTTONS = %w[none left middle right back forward].freeze
6
+ BUTTON_MASKS = {
7
+ "none" => 0,
8
+ "left" => 1,
9
+ "right" => 2,
10
+ "middle" => 4,
11
+ "back" => 8,
12
+ "forward" => 16
13
+ }.freeze
7
14
 
8
15
  def initialize(page)
9
16
  @page = page
10
17
  @x = @y = 0
18
+ @buttons = 0
19
+ end
20
+
21
+ #
22
+ # Scroll page by the given amount x, y.
23
+ #
24
+ # @param [Integer] x
25
+ # The horizontal pixel value that you want to scroll by.
26
+ #
27
+ # @param [Integer] y
28
+ # The vertical pixel value that you want to scroll by.
29
+ #
30
+ # @example
31
+ # browser.go_to("https://www.google.com/search?q=Ruby+headless+driver+for+Capybara")
32
+ # browser.mouse.scroll_by(0, 400)
33
+ #
34
+ def scroll_by(x, y)
35
+ tap { @page.execute("window.scrollBy(#{x}, #{y})") }
11
36
  end
12
37
 
13
38
  #
@@ -107,9 +132,9 @@ module Ferrum
107
132
  #
108
133
  # Mouse move to given x and y.
109
134
  #
110
- # @param [Integer] x
135
+ # @param [Number] x
111
136
  #
112
- # @param [Integer] y
137
+ # @param [Number] y
113
138
  #
114
139
  # @param [Integer] steps
115
140
  # Sends intermediate mousemove events.
@@ -129,8 +154,9 @@ module Ferrum
129
154
  @page.command("Input.dispatchMouseEvent",
130
155
  slowmoable: true,
131
156
  type: "mouseMoved",
132
- x: new_x.to_i,
133
- y: new_y.to_i)
157
+ x: new_x,
158
+ y: new_y,
159
+ buttons: @buttons)
134
160
  end
135
161
 
136
162
  self
@@ -140,16 +166,26 @@ module Ferrum
140
166
 
141
167
  def mouse_event(type:, button: :left, count: 1, modifiers: nil, wait: 0)
142
168
  button = validate_button(button)
143
- options = { x: @x, y: @y, type: type, button: button, clickCount: count }
169
+ register_event_button(type, button)
170
+ options = { x: @x, y: @y, type: type, button: button, buttons: @buttons, clickCount: count }
144
171
  options.merge!(modifiers: modifiers) if modifiers
145
172
  @page.command("Input.dispatchMouseEvent", wait: wait, slowmoable: true, **options)
146
173
  end
147
174
 
148
175
  def validate_button(button)
149
176
  button = button.to_s
150
- raise "Invalid button: #{button}" unless VALID_BUTTONS.include?(button)
177
+ raise "Invalid button: #{button}" unless BUTTON_MASKS.key?(button)
151
178
 
152
179
  button
153
180
  end
181
+
182
+ def register_event_button(type, button)
183
+ case type
184
+ when "mousePressed"
185
+ @buttons |= BUTTON_MASKS[button]
186
+ when "mouseReleased"
187
+ @buttons &= ~BUTTON_MASKS[button]
188
+ end
189
+ end
154
190
  end
155
191
  end
@@ -28,6 +28,15 @@ module Ferrum
28
28
  # @return [Error, nil]
29
29
  attr_accessor :error
30
30
 
31
+ # Determines if the network exchange is unknown due to
32
+ # a lost of its context
33
+ #
34
+ # @return Boolean
35
+ attr_accessor :unknown
36
+
37
+ # @api private
38
+ attr_accessor :request_extra_info
39
+
31
40
  #
32
41
  # Initializes the network exchange.
33
42
  #
@@ -40,6 +49,8 @@ module Ferrum
40
49
  @page = page
41
50
  @intercepted_request = nil
42
51
  @request = @response = @error = nil
52
+ @request_extra_info = nil
53
+ @unknown = false
43
54
  end
44
55
 
45
56
  #
@@ -54,6 +65,15 @@ module Ferrum
54
65
  request&.type?(:document) && request&.frame_id == frame_id
55
66
  end
56
67
 
68
+ #
69
+ # The loader ID of the request.
70
+ #
71
+ # @return [String, nil]
72
+ #
73
+ def loader_id
74
+ request&.loader_id
75
+ end
76
+
57
77
  #
58
78
  # Determines if the network exchange has a request.
59
79
  #
@@ -74,12 +94,12 @@ module Ferrum
74
94
 
75
95
  #
76
96
  # Determines if the request was blocked, a response was returned, or if an
77
- # error occurred.
97
+ # error occurred or the exchange is unknown and cannot be inferred.
78
98
  #
79
99
  # @return [Boolean]
80
100
  #
81
101
  def finished?
82
- blocked? || response&.loaded? || !error.nil? || ping?
102
+ blocked? || response&.loaded? || !error.nil? || ping? || blob? || unknown
83
103
  end
84
104
 
85
105
  #
@@ -127,6 +147,15 @@ module Ferrum
127
147
  !!request&.ping?
128
148
  end
129
149
 
150
+ #
151
+ # Determines if the exchange is blob.
152
+ #
153
+ # @return [Boolean]
154
+ #
155
+ def blob?
156
+ !!url&.start_with?("blob:")
157
+ end
158
+
130
159
  #
131
160
  # Returns request's URL.
132
161
  #
@@ -156,7 +185,8 @@ module Ferrum
156
185
  "@intercepted_request=#{@intercepted_request.inspect} " \
157
186
  "@request=#{@request.inspect} " \
158
187
  "@response=#{@response.inspect} " \
159
- "@error=#{@error.inspect}>"
188
+ "@error=#{@error.inspect}> " \
189
+ "@unknown=#{@unknown.inspect}>"
160
190
  end
161
191
  end
162
192
  end
@@ -71,6 +71,15 @@ module Ferrum
71
71
  @params["frameId"]
72
72
  end
73
73
 
74
+ #
75
+ # The loader ID of the request.
76
+ #
77
+ # @return [String]
78
+ #
79
+ def loader_id
80
+ @params["loaderId"]
81
+ end
82
+
74
83
  #
75
84
  # The request timestamp.
76
85
  #
@@ -100,7 +100,7 @@ module Ferrum
100
100
  #
101
101
  # Page request of the main frame.
102
102
  #
103
- # @return [Request]
103
+ # @return [Request, nil]
104
104
  #
105
105
  # @example
106
106
  # browser.go_to("https://github.com/")
@@ -380,15 +380,26 @@ module Ferrum
380
380
 
381
381
  # We can build exchange in two places, here on the event or when request
382
382
  # is interrupted. So we have to be careful when to create new one. We
383
- # create new exchange only if there's no with such id or there's but
383
+ # create new exchange only if there's no with such id or there's, but
384
384
  # it's filled with request which means this one is new but has response
385
385
  # for a redirect. So we assign response from the params to previous
386
386
  # exchange and build new exchange to assign this request to it.
387
387
  exchange = select(request.id).last
388
- exchange = build_exchange(request.id) unless exchange&.blank?
388
+ exchange = build_exchange(request.id) if exchange.nil? || !exchange.blank?
389
+ request.headers.merge!(Hash(exchange.request_extra_info&.dig("headers")))
389
390
  exchange.request = request
390
391
 
391
- @exchange = exchange if exchange.navigation_request?(@page.main_frame.id)
392
+ if exchange.navigation_request?(@page.main_frame.id)
393
+ @exchange = exchange
394
+ classify_pending_exchanges(exchange.loader_id)
395
+ end
396
+ end
397
+
398
+ @page.on("Network.requestWillBeSentExtraInfo") do |params|
399
+ exchange = select(params["requestId"]).last
400
+ exchange ||= build_exchange(params["requestId"])
401
+ exchange.request_extra_info = params
402
+ exchange.request&.headers&.merge!(params["headers"])
392
403
  end
393
404
  end
394
405
 
@@ -407,6 +418,8 @@ module Ferrum
407
418
  exchange = select(params["requestId"]).last
408
419
  next unless exchange
409
420
 
421
+ exchange.unknown = false
422
+
410
423
  if (response = exchange.response)
411
424
  response.loaded = true
412
425
  response.body_size = params["encodedDataLength"]
@@ -489,5 +502,17 @@ module Ferrum
489
502
  def whitelist?
490
503
  Array(@whitelist).any?
491
504
  end
505
+
506
+ # When the main frame navigates Chrome doesn't send `Network.loadingFailed`
507
+ # for pending async requests. Therefore, we mark pending connections as unknown since
508
+ # they are not relevant to the current navigation.
509
+ def classify_pending_exchanges(new_loader_id)
510
+ @traffic.each do |exchange|
511
+ break if exchange.loader_id == new_loader_id
512
+ next unless exchange.pending?
513
+
514
+ exchange.unknown = true
515
+ end
516
+ end
492
517
  end
493
518
  end
data/lib/ferrum/node.rb CHANGED
@@ -76,6 +76,7 @@ module Ferrum
76
76
  when :double
77
77
  page.mouse.move(x: x, y: y)
78
78
  page.mouse.down(modifiers: modifiers, count: 2)
79
+ sleep(delay)
79
80
  page.mouse.up(modifiers: modifiers, count: 2)
80
81
  when :left
81
82
  page.mouse.click(x: x, y: y, modifiers: modifiers, delay: delay)
@@ -124,7 +124,7 @@ module Ferrum
124
124
  on("Page.frameStoppedLoading") do |params|
125
125
  # `DOM.performSearch` doesn't work without getting #document node first.
126
126
  # It returns node with nodeId 1 and nodeType 9 from which descend the
127
- # tree and we save it in a variable because if we call that again root
127
+ # tree, and we save it in a variable because if we call that again root
128
128
  # node will change the id and all subsequent nodes have to change id too.
129
129
  if @main_frame.id == params["frameId"]
130
130
  @event.set if idling?
@@ -177,12 +177,16 @@ module Ferrum
177
177
  execution_id = params["executionContextId"]
178
178
  frame = frame_by(execution_id: execution_id)
179
179
  frame&.execution_id = nil
180
+ frame&.state = :stopped_loading
180
181
  end
181
182
  end
182
183
 
183
184
  def subscribe_execution_contexts_cleared
184
185
  on("Runtime.executionContextsCleared") do
185
- @frames.each_value { |f| f.execution_id = nil }
186
+ @frames.each_value do |f|
187
+ f.execution_id = nil
188
+ f.state = :stopped_loading
189
+ end
186
190
  end
187
191
  end
188
192
 
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Page
5
+ module Screencast
6
+ # Starts sending frames to record screencast to the given block.
7
+ #
8
+ # @param [Hash{Symbol => Object}] opts
9
+ #
10
+ # @option opts [:jpeg, :png] :format
11
+ # The format the image should be returned in.
12
+ #
13
+ # @option opts [Integer] :quality
14
+ # The image quality. **Note:** 0-100 works for JPEG only.
15
+ #
16
+ # @option opts [Integer] :max_width
17
+ # Maximum screencast frame width.
18
+ #
19
+ # @option opts [Integer] :max_height
20
+ # Maximum screencast frame height.
21
+ #
22
+ # @option opts [Integer] :every_nth_frame
23
+ # Send every n-th frame.
24
+ #
25
+ # @yield [data, metadata, session_id]
26
+ # The given block receives the screencast frame along with metadata
27
+ # about the frame and the screencast session ID.
28
+ #
29
+ # @yieldparam data [String]
30
+ # Base64-encoded compressed image.
31
+ #
32
+ # @yieldparam metadata [Hash{String => Object}]
33
+ # Screencast frame metadata.
34
+ #
35
+ # @option metadata [Integer] 'offsetTop'
36
+ # Top offset in DIP.
37
+ #
38
+ # @option metadata [Integer] 'pageScaleFactor'
39
+ # Page scale factor.
40
+ #
41
+ # @option metadata [Integer] 'deviceWidth'
42
+ # Device screen width in DIP.
43
+ #
44
+ # @option metadata [Integer] 'deviceHeight'
45
+ # Device screen height in DIP.
46
+ #
47
+ # @option metadata [Integer] 'scrollOffsetX'
48
+ # Position of horizontal scroll in CSS pixels.
49
+ #
50
+ # @option metadata [Integer] 'scrollOffsetY'
51
+ # Position of vertical scroll in CSS pixels.
52
+ #
53
+ # @option metadata [Float] 'timestamp'
54
+ # (optional) Frame swap timestamp in seconds since Unix epoch.
55
+ #
56
+ # @yieldparam session_id [Integer]
57
+ # Frame number.
58
+ #
59
+ # @example
60
+ # require "base64"
61
+ #
62
+ # page.go_to("https://apple.com/ipad")
63
+ #
64
+ # page.start_screencast(format: :jpeg, quality: 75) do |data, metadata|
65
+ # timestamp = (metadata['timestamp'] * 1000).to_i
66
+ # File.binwrite("image_#{timestamp}.jpg", Base64.decode64(data))
67
+ # end
68
+ #
69
+ # sleep 10
70
+ #
71
+ # page.stop_screencast
72
+ #
73
+ def start_screencast(**opts)
74
+ options = opts.transform_keys { START_SCREENCAST_KEY_CONV.fetch(_1, _1) }
75
+ response = command("Page.startScreencast", **options)
76
+
77
+ if (error_text = response["errorText"]) # https://cs.chromium.org/chromium/src/net/base/net_error_list.h
78
+ raise "Starting screencast failed (#{error_text})"
79
+ end
80
+
81
+ on("Page.screencastFrame") do |params|
82
+ data, metadata, session_id = params.values_at("data", "metadata", "sessionId")
83
+
84
+ command("Page.screencastFrameAck", sessionId: session_id)
85
+
86
+ yield data, metadata, session_id
87
+ end
88
+ end
89
+
90
+ # Stops sending frames.
91
+ def stop_screencast
92
+ command("Page.stopScreencast")
93
+ end
94
+
95
+ START_SCREENCAST_KEY_CONV = {
96
+ max_width: :maxWidth,
97
+ max_height: :maxHeight,
98
+ every_nth_frame: :everyNthFrame
99
+ }.freeze
100
+ end
101
+ end
102
+ end
data/lib/ferrum/page.rb CHANGED
@@ -10,6 +10,7 @@ require "ferrum/dialog"
10
10
  require "ferrum/network"
11
11
  require "ferrum/downloads"
12
12
  require "ferrum/page/frames"
13
+ require "ferrum/page/screencast"
13
14
  require "ferrum/page/screenshot"
14
15
  require "ferrum/page/animation"
15
16
  require "ferrum/page/tracing"
@@ -27,6 +28,7 @@ module Ferrum
27
28
  delegate %i[base_url default_user_agent timeout timeout=] => :@options
28
29
 
29
30
  include Animation
31
+ include Screencast
30
32
  include Screenshot
31
33
  include Frames
32
34
  include Stream
@@ -393,6 +395,19 @@ module Ferrum
393
395
  end
394
396
  end
395
397
 
398
+ def off(name, id)
399
+ case name
400
+ when :dialog
401
+ client.off("Page.javascriptDialogOpening", id)
402
+ when :request
403
+ client.off("Fetch.requestPaused", id)
404
+ when :auth
405
+ client.off("Fetch.authRequired", id)
406
+ else
407
+ client.off(name, id)
408
+ end
409
+ end
410
+
396
411
  def subscribed?(event)
397
412
  client.subscribed?(event)
398
413
  end
@@ -475,7 +490,7 @@ module Ferrum
475
490
  # opens a new window for which `frameStoppedLoading` event never
476
491
  # occurs and thus search for nodes cannot be completed. Here we check
477
492
  # the history and if the transitionType for example `link` then
478
- # content is already loaded and we can try to get the document.
493
+ # content is already loaded, and we can try to get the document.
479
494
  document_node_id
480
495
  end
481
496
 
data/lib/ferrum/target.rb CHANGED
@@ -8,7 +8,8 @@ module Ferrum
8
8
  # where we enhance page class and build page ourselves.
9
9
  attr_writer :page
10
10
 
11
- attr_reader :session_id, :options
11
+ attr_reader :options
12
+ attr_accessor :session_id
12
13
 
13
14
  def initialize(browser_client, session_id = nil, params = nil)
14
15
  @page = nil
@@ -67,11 +68,19 @@ module Ferrum
67
68
  !!opener_id
68
69
  end
69
70
 
71
+ def iframe?
72
+ type == "iframe"
73
+ end
74
+
70
75
  def maybe_sleep_if_new_window
71
76
  # Dirty hack because new window doesn't have events at all
72
77
  sleep(NEW_WINDOW_WAIT) if window?
73
78
  end
74
79
 
80
+ def command(...)
81
+ client.command(...)
82
+ end
83
+
75
84
  private
76
85
 
77
86
  def build_client
@@ -9,6 +9,10 @@ module Ferrum
9
9
  @start ||= monotonic_time
10
10
  end
11
11
 
12
+ def reset
13
+ @start = monotonic_time
14
+ end
15
+
12
16
  def elapsed_time(start = nil)
13
17
  monotonic_time - (start || @start)
14
18
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ferrum
4
- VERSION = "0.16"
4
+ VERSION = "0.17"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ferrum
3
3
  version: !ruby/object:Gem::Version
4
- version: '0.16'
4
+ version: '0.17'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Vorotilin
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-12-01 00:00:00.000000000 Z
11
+ date: 2025-05-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: addressable
@@ -129,6 +129,7 @@ files:
129
129
  - lib/ferrum/page.rb
130
130
  - lib/ferrum/page/animation.rb
131
131
  - lib/ferrum/page/frames.rb
132
+ - lib/ferrum/page/screencast.rb
132
133
  - lib/ferrum/page/screenshot.rb
133
134
  - lib/ferrum/page/stream.rb
134
135
  - lib/ferrum/page/tracing.rb