ferrum 0.5 → 0.6

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: 703207ec942dcaa7a09a6a2436e9a358da43a4f44e195150814ff1d00e05e3f8
4
- data.tar.gz: 27e7a22921a96e1fbf4d36215170fb884e4ec72e904686cb12eae014f6dfd0a6
3
+ metadata.gz: 4f657c088b22a9ff5809b6ecd0d7005cd0c2252645b262e76faee1b6e3af94c8
4
+ data.tar.gz: d22842188425c753e0a0e113be52e0176e8a7e1a97211c44bf3016e90aa0d1d4
5
5
  SHA512:
6
- metadata.gz: b241f4500a4d330b2da5209e789a20c0df95f37963a6031846f51e8950378885749a2c3217116ef37dc09a5efcc421eaf4b1301536a38b9eb0747f2e1037f904
7
- data.tar.gz: 60abc95e3633224aa08923da5b78026a687d21f380b3a818b99dcc1f32950fce83a7de574276f21973360ea257aaa8560490202e735953cbd6b714466eeefee8
6
+ metadata.gz: a519e80c6b1450ba85c555d4c5eb547ffb62f129e7873515ba2f5bad4726548289bcc3b4db95932873620e4b645f2f9b7f12c8bd0fdbe0ddada4f0faae080720
7
+ data.tar.gz: 91a8f95433f9630dd0f2b36c42ea14bf28b49ee9504738c664399eeb7fc3d192bd26c14ae7de707dcaa8273ec65d7b1a8e9bcaeeff4287392a5e1b312ea03b0d
data/README.md CHANGED
@@ -221,20 +221,20 @@ browser.xpath("//a[@aria-label='Issues you created']") # => [Node]
221
221
 
222
222
  #### current_url : `String`
223
223
 
224
- Returns current window location href.
224
+ Returns current top window location href.
225
225
 
226
226
  ```ruby
227
227
  browser.goto("https://google.com/")
228
228
  browser.current_url # => "https://www.google.com/"
229
229
  ```
230
230
 
231
- #### title : `String`
231
+ #### current_title : `String`
232
232
 
233
- Returns current window title
233
+ Returns current top window title
234
234
 
235
235
  ```ruby
236
236
  browser.goto("https://google.com/")
237
- browser.title # => "Google"
237
+ browser.current_title # => "Google"
238
238
  ```
239
239
 
240
240
  #### body : `String`
@@ -285,6 +285,8 @@ Saves PDF on a disk or returns it as base64.
285
285
  Base64
286
286
  * :landscape `Boolean` paper orientation. Defaults to false.
287
287
  * :scale `Float` zoom in/out
288
+ * :format `symbol` standard paper sizes :letter, :legal, :tabloid, :ledger, :A0, :A1, :A2, :A3, :A4, :A5, :A6
289
+
288
290
  * :paper_width `Float` set paper width
289
291
  * :paper_height `Float` set paper height
290
292
  * See other [native options](https://chromedevtools.github.io/devtools-protocol/tot/Page#method-printToPDF) you can pass
@@ -368,6 +370,8 @@ browser.network.intercept
368
370
  browser.on(:request) do |request|
369
371
  if request.match?(/bla-bla/)
370
372
  request.abort
373
+ elsif request.match?(/lorem/)
374
+ request.respond(body: "Lorem ipsum")
371
375
  else
372
376
  request.continue
373
377
  end
@@ -601,22 +605,43 @@ simple value.
601
605
  browser.execute(%(1 + 1)) # => true
602
606
  ```
603
607
 
608
+ #### add_script_tag(\*\*options) : `Boolean`
609
+
610
+ * options `Hash`
611
+ * :url `String`
612
+ * :path `String`
613
+ * :content `String`
614
+ * :type `String` - `text/javascript` by default
615
+
616
+ ```ruby
617
+ browser.add_script_tag(url: "http://example.com/stylesheet.css") # => true
618
+ ```
619
+
620
+ #### add_style_tag(\*\*options) : `Boolean`
621
+
622
+ * options `Hash`
623
+ * :url `String`
624
+ * :path `String`
625
+ * :content `String`
626
+
627
+ ```ruby
628
+ browser.add_style_tag(content: "h1 { font-size: 40px; }") # => true
629
+ ```
630
+
604
631
 
605
632
  ## Frames
606
633
 
607
- #### frame_url
608
- #### frame_title
609
- #### within_frame(frame, &block)
634
+ #### frames
635
+ #### main_frame
636
+ #### frame_by
610
637
 
611
638
  Play around inside given frame
612
639
 
613
640
  ```ruby
614
641
  browser.goto("https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe")
615
- frame = browser.at_xpath("//iframe")
616
- browser.within_frame(frame) do
617
- puts browser.frame_title # => HTML Demo: <iframe>
618
- puts browser.frame_url # => https://interactive-examples.mdn.mozilla.net/pages/tabbed/iframe.html
619
- end
642
+ frame = browser.frames[1]
643
+ puts frame.title # => HTML Demo: <iframe>
644
+ puts frame.url # => https://interactive-examples.mdn.mozilla.net/pages/tabbed/iframe.html
620
645
  ```
621
646
 
622
647
 
@@ -17,12 +17,13 @@ module Ferrum
17
17
  delegate %i[default_context] => :contexts
18
18
  delegate %i[targets create_target create_page page pages windows] => :default_context
19
19
  delegate %i[goto back forward refresh
20
- at_css at_xpath css xpath current_url title body
20
+ at_css at_xpath css xpath current_url title body doctype
21
21
  headers cookies network
22
22
  mouse keyboard
23
23
  screenshot pdf viewport_size
24
+ frames frame_by main_frame
24
25
  evaluate evaluate_on evaluate_async execute
25
- frame_url frame_title within_frame
26
+ add_script_tag add_style_tag
26
27
  on] => :page
27
28
 
28
29
  attr_reader :client, :process, :contexts, :logger, :js_errors,
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ferrum/frame/dom"
4
+ require "ferrum/frame/runtime"
5
+
6
+ module Ferrum
7
+ class Frame
8
+ include DOM, Runtime
9
+
10
+ attr_reader :id, :page, :parent_id, :state
11
+ attr_writer :execution_id
12
+ attr_accessor :name
13
+
14
+ def initialize(id, page, parent_id = nil)
15
+ @id, @page, @parent_id = id, page, parent_id
16
+ end
17
+
18
+ # Can be one of:
19
+ # * started_loading
20
+ # * navigated
21
+ # * scheduled_navigation
22
+ # * cleared_scheduled_navigation
23
+ # * stopped_loading
24
+ def state=(value)
25
+ @state = value
26
+ end
27
+
28
+ def url
29
+ evaluate("document.location.href")
30
+ end
31
+
32
+ def title
33
+ evaluate("document.title")
34
+ end
35
+
36
+ def main?
37
+ @parent_id.nil?
38
+ end
39
+
40
+ def execution_id
41
+ raise NoExecutionContextError unless @execution_id
42
+ @execution_id
43
+ rescue NoExecutionContextError
44
+ @page.event.reset
45
+ @page.event.wait(@page.timeout) ? retry : raise
46
+ end
47
+
48
+ def inspect
49
+ %(#<#{self.class} @id=#{@id.inspect} @parent_id=#{@parent_id.inspect} @name=#{@name.inspect} @state=#{@state.inspect} @execution_id=#{@execution_id.inspect}>)
50
+ end
51
+ end
52
+ end
@@ -18,16 +18,20 @@
18
18
  # inspected node (DOM.pushNodesByBackendIdsToFrontend) or described in more
19
19
  # details (DOM.describeNode).
20
20
  module Ferrum
21
- class Page
21
+ class Frame
22
22
  module DOM
23
23
  def current_url
24
24
  evaluate("window.top.location.href")
25
25
  end
26
26
 
27
- def title
27
+ def current_title
28
28
  evaluate("window.top.document.title")
29
29
  end
30
30
 
31
+ def doctype
32
+ evaluate("new XMLSerializer().serializeToString(document.doctype)")
33
+ end
34
+
31
35
  def body
32
36
  evaluate("document.documentElement.outerHTML")
33
37
  end
@@ -57,22 +61,23 @@ module Ferrum
57
61
  } else {
58
62
  throw error;
59
63
  }
60
- }), timeout, selector, within)
64
+ }), @page.timeout, selector, within)
61
65
  end
62
66
 
67
+ # FIXME css doesn't work for a frame w/o execution_id
63
68
  def css(selector, within: nil)
64
- node_id = within&.node_id || @document_id
69
+ node_id = within&.node_id || @page.document_id
65
70
 
66
- ids = command("DOM.querySelectorAll",
67
- nodeId: node_id,
68
- selector: selector)["nodeIds"]
71
+ ids = @page.command("DOM.querySelectorAll",
72
+ nodeId: node_id,
73
+ selector: selector)["nodeIds"]
69
74
  ids.map { |id| build_node(id) }.compact
70
75
  end
71
76
 
72
77
  def at_css(selector, within: nil)
73
- node_id = within&.node_id || @document_id
78
+ node_id = within&.node_id || @page.document_id
74
79
 
75
- id = command("DOM.querySelector",
80
+ id = @page.command("DOM.querySelector",
76
81
  nodeId: node_id,
77
82
  selector: selector)["nodeId"]
78
83
  build_node(id)
@@ -81,8 +86,8 @@ module Ferrum
81
86
  private
82
87
 
83
88
  def build_node(node_id)
84
- description = command("DOM.describeNode", nodeId: node_id)
85
- Node.new(self, target_id, node_id, description["node"])
89
+ description = @page.command("DOM.describeNode", nodeId: node_id)
90
+ Node.new(self, @page.target_id, node_id, description["node"])
86
91
  end
87
92
  end
88
93
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ferrum
4
- class Page
4
+ class Frame
5
5
  module Runtime
6
6
  INTERMITTENT_ATTEMPTS = ENV.fetch("FERRUM_INTERMITTENT_ATTEMPTS", 6).to_i
7
7
  INTERMITTENT_SLEEP = ENV.fetch("FERRUM_INTERMITTENT_SLEEP", 0.1).to_f
@@ -31,6 +31,35 @@ module Ferrum
31
31
  )
32
32
  }.freeze
33
33
 
34
+ SCRIPT_SRC_TAG = <<~JS
35
+ const script = document.createElement("script");
36
+ script.src = arguments[0];
37
+ script.type = arguments[1];
38
+ script.onload = arguments[2];
39
+ document.head.appendChild(script);
40
+ JS
41
+ SCRIPT_TEXT_TAG = <<~JS
42
+ const script = document.createElement("script");
43
+ script.text = arguments[0];
44
+ script.type = arguments[1];
45
+ document.head.appendChild(script);
46
+ arguments[2]();
47
+ JS
48
+ STYLE_TAG = <<~JS
49
+ const style = document.createElement("style");
50
+ style.type = "text/css";
51
+ style.appendChild(document.createTextNode(arguments[0]));
52
+ document.head.appendChild(style);
53
+ arguments[1]();
54
+ JS
55
+ LINK_TAG = <<~JS
56
+ const link = document.createElement("link");
57
+ link.rel = "stylesheet";
58
+ link.href = arguments[0];
59
+ link.onload = arguments[1];
60
+ document.head.appendChild(link);
61
+ JS
62
+
34
63
  def evaluate(expression, *args)
35
64
  call(*args, expression: expression)
36
65
  end
@@ -49,13 +78,13 @@ module Ferrum
49
78
  attempts, sleep = INTERMITTENT_ATTEMPTS, INTERMITTENT_SLEEP
50
79
 
51
80
  Ferrum.with_attempts(errors: errors, max: attempts, wait: sleep) do
52
- response = command("DOM.resolveNode", nodeId: node.node_id)
81
+ response = @page.command("DOM.resolveNode", nodeId: node.node_id)
53
82
  object_id = response.dig("object", "objectId")
54
83
  options = DEFAULT_OPTIONS.merge(objectId: object_id)
55
84
  options[:functionDeclaration] = options[:functionDeclaration] % expression
56
85
  options.merge!(returnByValue: by_value)
57
86
 
58
- response = command("Runtime.callFunctionOn",
87
+ response = @page.command("Runtime.callFunctionOn",
59
88
  wait: wait, **options)["result"]
60
89
  .tap { |r| handle_error(r) }
61
90
 
@@ -63,6 +92,34 @@ module Ferrum
63
92
  end
64
93
  end
65
94
 
95
+ def add_script_tag(url: nil, path: nil, content: nil, type: "text/javascript")
96
+ expr, *args = if url
97
+ [SCRIPT_SRC_TAG, url, type]
98
+ elsif path || content
99
+ if path
100
+ content = File.read(path)
101
+ content += "\n//# sourceURL=#{path}"
102
+ end
103
+ [SCRIPT_TEXT_TAG, content, type]
104
+ end
105
+
106
+ evaluate_async(expr, @page.timeout, *args)
107
+ end
108
+
109
+ def add_style_tag(url: nil, path: nil, content: nil)
110
+ expr, *args = if url
111
+ [LINK_TAG, url]
112
+ elsif path || content
113
+ if path
114
+ content = File.read(path)
115
+ content += "\n//# sourceURL=#{path}"
116
+ end
117
+ [STYLE_TAG, content]
118
+ end
119
+
120
+ evaluate_async(expr, @page.timeout, *args)
121
+ end
122
+
66
123
  private
67
124
 
68
125
  def call(*args, expression:, wait_time: nil, handle: true, **options)
@@ -76,10 +133,10 @@ module Ferrum
76
133
  params[:functionDeclaration] = params[:functionDeclaration] % expression
77
134
  params = params.merge(arguments: arguments)
78
135
  unless params[:executionContextId]
79
- params = params.merge(executionContextId: execution_context_id)
136
+ params = params.merge(executionContextId: execution_id)
80
137
  end
81
138
 
82
- response = command("Runtime.callFunctionOn",
139
+ response = @page.command("Runtime.callFunctionOn",
83
140
  **params)["result"].tap { |r| handle_error(r) }
84
141
 
85
142
  handle ? handle_response(response) : response
@@ -115,9 +172,9 @@ module Ferrum
115
172
  # and node destroyed so we need to retrieve it each time for given id.
116
173
  # Though we can try to subscribe to `DOM.childNodeRemoved` and
117
174
  # `DOM.childNodeInserted` in the future.
118
- node_id = command("DOM.requestNode", objectId: object_id)["nodeId"]
119
- description = command("DOM.describeNode", nodeId: node_id)["node"]
120
- Node.new(self, target_id, node_id, description)
175
+ node_id = @page.command("DOM.requestNode", objectId: object_id)["nodeId"]
176
+ description = @page.command("DOM.describeNode", nodeId: node_id)["node"]
177
+ Node.new(self, @page.target_id, node_id, description)
121
178
  when "array"
122
179
  reduce_props(object_id, []) do |memo, key, value|
123
180
  next(memo) unless (Integer(key) rescue nil)
@@ -140,7 +197,7 @@ module Ferrum
140
197
  def prepare_args(args)
141
198
  args.map do |arg|
142
199
  if arg.is_a?(Node)
143
- resolved = command("DOM.resolveNode", nodeId: arg.node_id)
200
+ resolved = @page.command("DOM.resolveNode", nodeId: arg.node_id)
144
201
  { objectId: resolved["object"]["objectId"] }
145
202
  elsif arg.is_a?(Hash) && arg["objectId"]
146
203
  { objectId: arg["objectId"] }
@@ -154,7 +211,7 @@ module Ferrum
154
211
  if cyclic?(object_id).dig("result", "value")
155
212
  return "(cyclic structure)"
156
213
  else
157
- props = command("Runtime.getProperties", objectId: object_id)
214
+ props = @page.command("Runtime.getProperties", ownProperties: true, objectId: object_id)
158
215
  props["result"].reduce(to) do |memo, prop|
159
216
  next(memo) unless prop["enumerable"]
160
217
  yield(memo, prop["name"], prop["value"])
@@ -163,7 +220,7 @@ module Ferrum
163
220
  end
164
221
 
165
222
  def cyclic?(object_id)
166
- command("Runtime.callFunctionOn",
223
+ @page.command("Runtime.callFunctionOn",
167
224
  objectId: object_id,
168
225
  returnByValue: true,
169
226
  functionDeclaration: <<~JS
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "ferrum/network/exchange"
4
4
  require "ferrum/network/intercepted_request"
5
+ require "ferrum/network/auth_request"
5
6
 
6
7
  module Ferrum
7
8
  class Network
@@ -51,7 +52,7 @@ module Ferrum
51
52
  pattern[:resourceType] = resource_type
52
53
  end
53
54
 
54
- @page.command("Network.setRequestInterception", patterns: [pattern])
55
+ @page.command("Fetch.enable", handleAuthRequests: true, patterns: [pattern])
55
56
  end
56
57
 
57
58
  def authorize(user:, password:, type: :server)
@@ -64,18 +65,22 @@ module Ferrum
64
65
 
65
66
  intercept
66
67
 
67
- @page.on(:request) do |request, index, total|
68
+ @page.on(:request) do |request|
69
+ request.continue
70
+ end
71
+
72
+ @page.on(:auth) do |request, index, total|
68
73
  if request.auth_challenge?(type)
69
74
  response = authorized_response(@authorized_ids[type],
70
- request.interception_id,
75
+ request.request_id,
71
76
  user, password)
72
77
 
73
- @authorized_ids[type] << request.interception_id
78
+ @authorized_ids[type] << request.request_id
74
79
  request.continue(authChallengeResponse: response)
75
80
  elsif index + 1 < total
76
81
  next # There are other callbacks that can handle this
77
82
  else
78
- request.continue
83
+ request.abort
79
84
  end
80
85
  end
81
86
  end
@@ -90,8 +95,8 @@ module Ferrum
90
95
  exchange.build_response(params)
91
96
  end
92
97
 
93
- exchange = Network::Exchange.new(params)
94
- @exchange = exchange if exchange.navigation_request?(@page.frame_id)
98
+ exchange = Network::Exchange.new(@page, params)
99
+ @exchange = exchange if exchange.navigation_request?(@page.main_frame.id)
95
100
  @traffic << exchange
96
101
  end
97
102
 
@@ -118,8 +123,8 @@ module Ferrum
118
123
  end
119
124
  end
120
125
 
121
- def authorized_response(ids, interception_id, username, password)
122
- if ids.include?(interception_id)
126
+ def authorized_response(ids, request_id, username, password)
127
+ if ids.include?(request_id)
123
128
  { response: "CancelAuth" }
124
129
  elsif username && password
125
130
  { response: "ProvideCredentials",
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ferrum
4
+ class Network
5
+ class AuthRequest
6
+ attr_accessor :request_id, :frame_id, :resource_type
7
+
8
+ def initialize(page, params)
9
+ @page, @params = page, params
10
+ @request_id = params["requestId"]
11
+ @frame_id = params["frameId"]
12
+ @resource_type = params["resourceType"]
13
+ @request = params["request"]
14
+ end
15
+
16
+ def navigation_request?
17
+ @params["isNavigationRequest"]
18
+ end
19
+
20
+ def auth_challenge?(source)
21
+ @params.dig("authChallenge", "source")&.downcase&.to_s == source.to_s
22
+ end
23
+
24
+ def match?(regexp)
25
+ !!url.match(regexp)
26
+ end
27
+
28
+ def continue(**options)
29
+ options = options.merge(requestId: request_id)
30
+ @page.command("Fetch.continueWithAuth", **options)
31
+ end
32
+
33
+ def abort
34
+ @page.command("Fetch.failRequest", requestId: request_id, errorReason: "BlockedByClient")
35
+ end
36
+
37
+ def url
38
+ @request["url"]
39
+ end
40
+
41
+ def method
42
+ @request["method"]
43
+ end
44
+
45
+ def headers
46
+ @request["headers"]
47
+ end
48
+
49
+ def initial_priority
50
+ @request["initialPriority"]
51
+ end
52
+
53
+ def referrer_policy
54
+ @request["referrerPolicy"]
55
+ end
56
+
57
+ def inspect
58
+ %(#<#{self.class} @request_id=#{@request_id.inspect} @frame_id=#{@frame_id.inspect} @resource_type=#{@resource_type.inspect} @request=#{@request.inspect}>)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -9,7 +9,8 @@ module Ferrum
9
9
  class Exchange
10
10
  attr_reader :request, :response, :error
11
11
 
12
- def initialize(params)
12
+ def initialize(page, params)
13
+ @page = page
13
14
  @response = @error = nil
14
15
  build_request(params)
15
16
  end
@@ -19,7 +20,7 @@ module Ferrum
19
20
  end
20
21
 
21
22
  def build_response(params)
22
- @response = Network::Response.new(params)
23
+ @response = Network::Response.new(@page, params)
23
24
  end
24
25
 
25
26
  def build_error(params)
@@ -38,6 +39,10 @@ module Ferrum
38
39
  def to_a
39
40
  [request, response, error]
40
41
  end
42
+
43
+ def inspect
44
+ %(#<#{self.class} @id=#{@id.inspect} @request=#{@request.inspect} @response=#{@response.inspect} @error=#{@error.inspect}>)
45
+ end
41
46
  end
42
47
  end
43
48
  end
@@ -1,13 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "base64"
4
+
3
5
  module Ferrum
4
6
  class Network
5
7
  class InterceptedRequest
6
- attr_accessor :interception_id, :frame_id, :resource_type
8
+ attr_accessor :request_id, :frame_id, :resource_type
7
9
 
8
10
  def initialize(page, params)
9
11
  @page, @params = page, params
10
- @interception_id = params["interceptionId"]
12
+ @request_id = params["requestId"]
11
13
  @frame_id = params["frameId"]
12
14
  @resource_type = params["resourceType"]
13
15
  @request = params["request"]
@@ -17,21 +19,32 @@ module Ferrum
17
19
  @params["isNavigationRequest"]
18
20
  end
19
21
 
20
- def auth_challenge?(source)
21
- @params.dig("authChallenge", "source")&.downcase&.to_s == source.to_s
22
- end
23
-
24
22
  def match?(regexp)
25
23
  !!url.match(regexp)
26
24
  end
27
25
 
26
+ def respond(**options)
27
+ has_body = options.has_key?(:body)
28
+ headers = has_body ? { "content-length" => options.fetch(:body, '').length } : {}
29
+ headers = headers.merge(options.fetch(:responseHeaders, {}))
30
+
31
+ options = {responseCode: 200}.merge(options)
32
+ options = options.merge({
33
+ requestId: request_id,
34
+ responseHeaders: header_array(headers),
35
+ })
36
+ options = options.merge(body: Base64.encode64(options.fetch(:body, '')).strip) if has_body
37
+
38
+ @page.command("Fetch.fulfillRequest", **options)
39
+ end
40
+
28
41
  def continue(**options)
29
- options = options.merge(interceptionId: interception_id)
30
- @page.command("Network.continueInterceptedRequest", **options)
42
+ options = options.merge(requestId: request_id)
43
+ @page.command("Fetch.continueRequest", **options)
31
44
  end
32
45
 
33
46
  def abort
34
- continue(errorReason: "Aborted")
47
+ @page.command("Fetch.failRequest", requestId: request_id, errorReason: "BlockedByClient")
35
48
  end
36
49
 
37
50
  def url
@@ -55,7 +68,15 @@ module Ferrum
55
68
  end
56
69
 
57
70
  def inspect
58
- %(#<#{self.class} @interception_id=#{@interception_id.inspect} @frame_id=#{@frame_id.inspect} @resource_type=#{@resource_type.inspect} @request=#{@request.inspect}>)
71
+ %(#<#{self.class} @request_id=#{@request_id.inspect} @frame_id=#{@frame_id.inspect} @resource_type=#{@resource_type.inspect} @request=#{@request.inspect}>)
72
+ end
73
+
74
+ private
75
+
76
+ def header_array(values)
77
+ values.map do |key, value|
78
+ { name: String(key), value: String(value) }
79
+ end
59
80
  end
60
81
  end
61
82
  end
@@ -5,9 +5,10 @@ module Ferrum
5
5
  class Response
6
6
  attr_reader :body_size
7
7
 
8
- def initialize(params)
8
+ def initialize(page, params)
9
+ @page = page
9
10
  @params = params
10
- @response = params["response"] || @params["redirectResponse"]
11
+ @response = params["response"] || params["redirectResponse"]
11
12
  end
12
13
 
13
14
  def id
@@ -35,7 +36,7 @@ module Ferrum
35
36
  end
36
37
 
37
38
  def content_type
38
- @content_type ||= @response.dig("headers", "contentType")&.sub(/;.*\z/, "")
39
+ @content_type ||= headers.find { |k, _| k.downcase == "content-type" }&.last&.sub(/;.*\z/, "")
39
40
  end
40
41
 
41
42
  # See https://crbug.com/883475
@@ -46,6 +47,27 @@ module Ferrum
46
47
  def body_size=(size)
47
48
  @body_size = size - headers_size
48
49
  end
50
+
51
+ def body
52
+ @body ||= begin
53
+ body, encoded = @page
54
+ .command("Network.getResponseBody", requestId: id)
55
+ .values_at("body", "base64Encoded")
56
+ encoded ? Base64.decode64(body) : body
57
+ end
58
+ end
59
+
60
+ def main?
61
+ @page.network.response == self
62
+ end
63
+
64
+ def ==(other)
65
+ id == other.id
66
+ end
67
+
68
+ def inspect
69
+ %(#<#{self.class} @params=#{@params.inspect} @response=#{@response.inspect}>)
70
+ end
49
71
  end
50
72
  end
51
73
  end
@@ -4,8 +4,9 @@ module Ferrum
4
4
  class Node
5
5
  attr_reader :page, :target_id, :node_id, :description, :tag_name
6
6
 
7
- def initialize(page, target_id, node_id, description)
8
- @page, @target_id = page, target_id
7
+ def initialize(frame, target_id, node_id, description)
8
+ @page = frame.page
9
+ @target_id = target_id
9
10
  @node_id, @description = node_id, description
10
11
  @tag_name = description["nodeName"].downcase
11
12
  end
@@ -14,6 +15,14 @@ module Ferrum
14
15
  description["nodeType"] == 1 # nodeType: 3, nodeName: "#text" e.g.
15
16
  end
16
17
 
18
+ def frame_id
19
+ description["frameId"]
20
+ end
21
+
22
+ def frame
23
+ page.frame_by(id: frame_id)
24
+ end
25
+
17
26
  def focus
18
27
  tap { page.command("DOM.focus", nodeId: node_id) }
19
28
  end
@@ -1,14 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "forwardable"
3
4
  require "ferrum/mouse"
4
5
  require "ferrum/keyboard"
5
6
  require "ferrum/headers"
6
7
  require "ferrum/cookies"
7
8
  require "ferrum/dialog"
8
9
  require "ferrum/network"
9
- require "ferrum/page/dom"
10
- require "ferrum/page/runtime"
11
- require "ferrum/page/frame"
10
+ require "ferrum/page/frames"
12
11
  require "ferrum/page/screenshot"
13
12
  require "ferrum/browser/client"
14
13
 
@@ -28,21 +27,24 @@ module Ferrum
28
27
  end
29
28
  end
30
29
 
31
- include DOM, Runtime, Frame, Screenshot
30
+ extend Forwardable
31
+ delegate %i[at_css at_xpath css xpath
32
+ current_url current_title url title body doctype
33
+ execution_id evaluate evaluate_on evaluate_async execute
34
+ add_script_tag add_style_tag] => :main_frame
35
+
36
+ include Frames, Screenshot
32
37
 
33
38
  attr_accessor :referrer
34
39
  attr_reader :target_id, :browser,
35
40
  :headers, :cookies, :network,
36
- :mouse, :keyboard
41
+ :mouse, :keyboard, :event, :document_id
37
42
 
38
43
  def initialize(target_id, browser)
44
+ @frames = {}
39
45
  @target_id, @browser = target_id, browser
40
46
  @event = Event.new.tap(&:set)
41
47
 
42
- @frames = {}
43
- @waiting_frames ||= Set.new
44
- @frame_stack = []
45
-
46
48
  host = @browser.process.host
47
49
  port = @browser.process.port
48
50
  ws_url = "ws://#{host}:#{port}/devtools/page/#{@target_id}"
@@ -85,12 +87,18 @@ module Ferrum
85
87
  @window_id, @bounds = result.values_at("windowId", "bounds")
86
88
 
87
89
  if fullscreen
90
+ width, height = document_size
88
91
  @browser.command("Browser.setWindowBounds", windowId: @window_id, bounds: { windowState: "fullscreen" })
89
92
  else
90
93
  @browser.command("Browser.setWindowBounds", windowId: @window_id, bounds: { windowState: "normal" })
91
94
  @browser.command("Browser.setWindowBounds", windowId: @window_id, bounds: { width: width, height: height, windowState: "normal" })
92
- command("Emulation.setDeviceMetricsOverride", width: width, height: height, deviceScaleFactor: 1, mobile: false)
93
95
  end
96
+
97
+ command("Emulation.setDeviceMetricsOverride", width: width,
98
+ height: height,
99
+ deviceScaleFactor: 1,
100
+ mobile: false,
101
+ fitWindow: false)
94
102
  end
95
103
 
96
104
  def refresh
@@ -123,10 +131,15 @@ module Ferrum
123
131
  block.call(dialog, index, total)
124
132
  end
125
133
  when :request
126
- @client.on("Network.requestIntercepted") do |params, index, total|
134
+ @client.on("Fetch.requestPaused") do |params, index, total|
127
135
  request = Network::InterceptedRequest.new(self, params)
128
136
  block.call(request, index, total)
129
137
  end
138
+ when :auth
139
+ @client.on("Fetch.authRequired") do |params, index, total|
140
+ request = Network::AuthRequest.new(self, params)
141
+ block.call(request, index, total)
142
+ end
130
143
  else
131
144
  @client.on(name, &block)
132
145
  end
@@ -135,19 +148,9 @@ module Ferrum
135
148
  private
136
149
 
137
150
  def subscribe
138
- super
139
-
151
+ frames_subscribe
140
152
  network.subscribe
141
153
 
142
- on("Network.loadingFailed") do |params|
143
- id, canceled = params.values_at("requestId", "canceled")
144
- # Set event as we aborted main request we are waiting for
145
- if network.request&.id == id && canceled == true
146
- @event.set
147
- @document_id = get_document_id
148
- end
149
- end
150
-
151
154
  if @browser.logger
152
155
  on("Runtime.consoleAPICalled") do |params|
153
156
  params["args"].each { |r| @browser.logger.puts(r["value"]) }
@@ -161,24 +164,20 @@ module Ferrum
161
164
  end
162
165
  end
163
166
 
164
- on("Page.navigatedWithinDocument") do
165
- @event.set if @waiting_frames.empty?
166
- end
167
-
168
167
  on("Page.domContentEventFired") do |params|
169
168
  # `frameStoppedLoading` doesn't occur if status isn't success
170
169
  if network.status != 200
171
170
  @event.set
172
- @document_id = get_document_id
171
+ get_document_id
173
172
  end
174
173
  end
175
174
  end
176
175
 
177
176
  def prepare_page
178
177
  command("Page.enable")
178
+ command("Runtime.enable")
179
179
  command("DOM.enable")
180
180
  command("CSS.enable")
181
- command("Runtime.enable")
182
181
  command("Log.enable")
183
182
  command("Network.enable")
184
183
 
@@ -202,7 +201,7 @@ module Ferrum
202
201
  # occurs and thus search for nodes cannot be completed. Here we check
203
202
  # the history and if the transitionType for example `link` then
204
203
  # content is already loaded and we can try to get the document.
205
- @document_id = get_document_id
204
+ get_document_id
206
205
  end
207
206
  end
208
207
 
@@ -214,7 +213,7 @@ module Ferrum
214
213
  # We also evaluate script just in case because
215
214
  # `Page.addScriptToEvaluateOnNewDocument` doesn't work in popups.
216
215
  command("Runtime.evaluate", expression: extension,
217
- contextId: execution_context_id,
216
+ contextId: execution_id,
218
217
  returnByValue: true)
219
218
  end
220
219
  end
@@ -241,7 +240,7 @@ module Ferrum
241
240
  end
242
241
 
243
242
  def get_document_id
244
- command("DOM.getDocument", depth: 0).dig("root", "nodeId")
243
+ @document_id = command("DOM.getDocument", depth: 0).dig("root", "nodeId")
245
244
  end
246
245
  end
247
246
  end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ferrum/frame"
4
+
5
+ module Ferrum
6
+ class Page
7
+ module Frames
8
+ attr_reader :main_frame
9
+
10
+ def frames
11
+ @frames.values
12
+ end
13
+
14
+ def frame_by(id: nil, execution_id: nil, name: nil)
15
+ if id
16
+ @frames[id]
17
+ elsif execution_id
18
+ frames.find { |f| f.execution_id == execution_id }
19
+ elsif name
20
+ frames.find { |f| f.name == name }
21
+ else
22
+ raise ArgumentError
23
+ end
24
+ end
25
+
26
+ def frames_subscribe
27
+ on("Page.frameAttached") do |params|
28
+ parent_frame_id, frame_id = params.values_at("parentFrameId", "frameId")
29
+ @frames[frame_id] = Frame.new(frame_id, self, parent_frame_id)
30
+ end
31
+
32
+ on("Page.frameStartedLoading") do |params|
33
+ frame = @frames[params["frameId"]]
34
+ frame.state = :started_loading
35
+ @event.reset
36
+ end
37
+
38
+ on("Page.frameNavigated") do |params|
39
+ frame_id, name = params["frame"]&.values_at("id", "name")
40
+ frame = @frames[frame_id]
41
+ frame.state = :navigated
42
+ frame.name = name unless name.to_s.empty?
43
+ end
44
+
45
+ on("Page.frameScheduledNavigation") do |params|
46
+ frame = @frames[params["frameId"]]
47
+ frame.state = :scheduled_navigation
48
+ @event.reset
49
+ end
50
+
51
+ on("Page.frameClearedScheduledNavigation") do |params|
52
+ frame = @frames[params["frameId"]]
53
+ frame.state = :cleared_scheduled_navigation
54
+ @event.set if idling?
55
+ end
56
+
57
+ on("Page.frameStoppedLoading") do |params|
58
+ # `DOM.performSearch` doesn't work without getting #document node first.
59
+ # It returns node with nodeId 1 and nodeType 9 from which descend the
60
+ # tree and we save it in a variable because if we call that again root
61
+ # node will change the id and all subsequent nodes have to change id too.
62
+ if @main_frame.id == params["frameId"]
63
+ @event.set if idling?
64
+ get_document_id
65
+ end
66
+
67
+ frame = @frames[params["frameId"]]
68
+ frame.state = :stopped_loading
69
+
70
+ @event.set if idling?
71
+ end
72
+
73
+ on("Page.navigatedWithinDocument") do
74
+ @event.set if idling?
75
+ end
76
+
77
+ on("Network.requestWillBeSent") do |params|
78
+ if params["frameId"] == @main_frame.id
79
+ # Possible types:
80
+ # Document, Stylesheet, Image, Media, Font, Script, TextTrack, XHR,
81
+ # Fetch, EventSource, WebSocket, Manifest, SignedExchange, Ping,
82
+ # CSPViolationReport, Other
83
+ @event.reset if params["type"] == "Document"
84
+ end
85
+ end
86
+
87
+ on("Runtime.executionContextCreated") do |params|
88
+ context_id = params.dig("context", "id")
89
+ frame_id = params.dig("context", "auxData", "frameId")
90
+ frame = @frames[frame_id] || Frame.new(frame_id, self)
91
+ frame.execution_id = context_id
92
+
93
+ @main_frame ||= frame
94
+ @frames[frame_id] ||= frame
95
+ end
96
+
97
+ on("Runtime.executionContextDestroyed") do |params|
98
+ execution_id = params["executionContextId"]
99
+ frame = frame_by(execution_id: execution_id)
100
+ frame.execution_id = nil
101
+ end
102
+
103
+ on("Runtime.executionContextsCleared") do
104
+ @frames.delete_if { |_, f| !f.main? }
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def idling?
111
+ @frames.all? { |_, f| f.state == :stopped_loading }
112
+ end
113
+ end
114
+ end
115
+ end
@@ -3,10 +3,31 @@
3
3
  module Ferrum
4
4
  class Page
5
5
  module Screenshot
6
+ DEFAULT_PDF_OPTIONS = {
7
+ landscape: false,
8
+ paper_width: 8.5,
9
+ paper_height: 11,
10
+ scale: 1.0
11
+ }.freeze
12
+
13
+ PAPEP_FORMATS = {
14
+ letter: { width: 8.50, height: 11.00 },
15
+ legal: { width: 8.50, height: 14.00 },
16
+ tabloid: { width: 11.00, height: 17.00 },
17
+ ledger: { width: 17.00, height: 11.00 },
18
+ A0: { width: 33.10, height: 46.80 },
19
+ A1: { width: 23.40, height: 33.10 },
20
+ A2: { width: 16.54, height: 23.40 },
21
+ A3: { width: 11.70, height: 16.54 },
22
+ A4: { width: 8.27, height: 11.70 },
23
+ A5: { width: 5.83, height: 8.27 },
24
+ A6: { width: 4.13, height: 5.83 },
25
+ }.freeze
26
+
6
27
  def screenshot(**opts)
7
28
  path, encoding = common_options(**opts)
8
29
  options = screenshot_options(path, **opts)
9
- data = command("Page.captureScreenshot", **options).fetch("data")
30
+ data = capture_screenshot(options, opts[:full])
10
31
  return data if encoding == :base64
11
32
  save_file(path, data)
12
33
  end
@@ -46,13 +67,21 @@ module Ferrum
46
67
  [path, encoding]
47
68
  end
48
69
 
49
- def pdf_options(landscape: false, paper_width: 8.5, paper_height: 11, scale: 1.0, **opts)
50
- options = {}
51
- options[:landscape] = landscape
52
- options[:paperWidth] = paper_width.to_f
53
- options[:paperHeight] = paper_height.to_f
54
- options[:scale] = scale.to_f
55
- options.merge(opts)
70
+ def pdf_options(**opts)
71
+ format = opts.delete(:format)
72
+ options = DEFAULT_PDF_OPTIONS.merge(opts)
73
+
74
+ if format
75
+ if opts[:paper_width] || opts[:paper_height]
76
+ raise ArgumentError, "Specify :format or :paper_width, :paper_height"
77
+ end
78
+
79
+ dimension = PAPEP_FORMATS.fetch(format)
80
+ options.merge!(paper_width: dimension[:width],
81
+ paper_height: dimension[:height])
82
+ end
83
+
84
+ options.map { |k, v| [to_camel_case(k), v] }.to_h
56
85
  end
57
86
 
58
87
  def screenshot_options(path = nil, format: nil, scale: 1.0, **opts)
@@ -73,8 +102,7 @@ module Ferrum
73
102
  width, height = document_size
74
103
  options.merge!(clip: { x: 0, y: 0, width: width, height: height, scale: scale }) if width > 0 && height > 0
75
104
  elsif opts[:selector]
76
- rect = evaluate("document.querySelector('#{opts[:selector]}').getBoundingClientRect()")
77
- options.merge!(clip: { x: rect["x"], y: rect["y"], width: rect["width"], height: rect["height"], scale: scale })
105
+ options.merge!(clip: get_bounding_rect(opts[:selector]).merge(scale: scale))
78
106
  end
79
107
 
80
108
  if scale != 1.0
@@ -88,6 +116,40 @@ module Ferrum
88
116
 
89
117
  options
90
118
  end
119
+
120
+ def get_bounding_rect(selector)
121
+ rect = evaluate_async(%Q(
122
+ const rect = document
123
+ .querySelector('#{selector}')
124
+ .getBoundingClientRect();
125
+ const {x, y, width, height} = rect;
126
+ arguments[0]([x, y, width, height])
127
+ ), timeout)
128
+
129
+ { x: rect[0], y: rect[1], width: rect[2], height: rect[3] }
130
+ end
131
+
132
+ def to_camel_case(option)
133
+ return :preferCSSPageSize if option == :prefer_css_page_size
134
+ option.to_s.gsub(/(?:_|(\/))([a-z\d]*)/) { "#{$1}#{$2.capitalize}" }.to_sym
135
+ end
136
+
137
+ def capture_screenshot(options, full)
138
+ maybe_resize_fullscreen(full) do
139
+ command("Page.captureScreenshot", options)
140
+ end.fetch("data")
141
+ end
142
+
143
+ def maybe_resize_fullscreen(full)
144
+ if full
145
+ width, height = viewport_size.dup
146
+ resize(fullscreen: true)
147
+ end
148
+
149
+ yield
150
+ ensure
151
+ resize(width: width, height: height) if full
152
+ end
91
153
  end
92
154
  end
93
155
  end
@@ -13,6 +13,10 @@ module Ferrum
13
13
  @params = params
14
14
  end
15
15
 
16
+ def attached?
17
+ !!@page
18
+ end
19
+
16
20
  def page
17
21
  @page ||= begin
18
22
  # Dirty hack because new window doesn't have events at all
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ferrum
4
- VERSION = "0.5"
4
+ VERSION = "0.6"
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.5'
4
+ version: '0.6'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Vorotilin
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-09-27 00:00:00.000000000 Z
11
+ date: 2019-10-29 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: websocket-driver
@@ -189,11 +189,15 @@ files:
189
189
  - lib/ferrum/contexts.rb
190
190
  - lib/ferrum/cookies.rb
191
191
  - lib/ferrum/dialog.rb
192
+ - lib/ferrum/frame.rb
193
+ - lib/ferrum/frame/dom.rb
194
+ - lib/ferrum/frame/runtime.rb
192
195
  - lib/ferrum/headers.rb
193
196
  - lib/ferrum/keyboard.json
194
197
  - lib/ferrum/keyboard.rb
195
198
  - lib/ferrum/mouse.rb
196
199
  - lib/ferrum/network.rb
200
+ - lib/ferrum/network/auth_request.rb
197
201
  - lib/ferrum/network/error.rb
198
202
  - lib/ferrum/network/exchange.rb
199
203
  - lib/ferrum/network/intercepted_request.rb
@@ -201,9 +205,7 @@ files:
201
205
  - lib/ferrum/network/response.rb
202
206
  - lib/ferrum/node.rb
203
207
  - lib/ferrum/page.rb
204
- - lib/ferrum/page/dom.rb
205
- - lib/ferrum/page/frame.rb
206
- - lib/ferrum/page/runtime.rb
208
+ - lib/ferrum/page/frames.rb
207
209
  - lib/ferrum/page/screenshot.rb
208
210
  - lib/ferrum/target.rb
209
211
  - lib/ferrum/version.rb
@@ -1,135 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ferrum
4
- class Page
5
- module Frame
6
- attr_reader :frame_id
7
-
8
- def execution_context_id
9
- context_id = current_execution_context_id
10
- raise NoExecutionContextError unless context_id
11
- context_id
12
- rescue NoExecutionContextError
13
- @event.reset
14
- @event.wait(timeout) ? retry : raise
15
- end
16
-
17
- def frame_name
18
- evaluate("window.name")
19
- end
20
-
21
- def frame_url
22
- evaluate("window.location.href")
23
- end
24
-
25
- def frame_title
26
- evaluate("document.title")
27
- end
28
-
29
- def within_frame(frame)
30
- unless frame.is_a?(Node)
31
- raise ArgumentError, "Node is expected, but #{frame.class} is given"
32
- end
33
-
34
- frame_id = frame.description["frameId"]
35
- @frame_stack << frame_id
36
- inject_extensions
37
- yield
38
- ensure
39
- @frame_stack.pop
40
- end
41
-
42
- private
43
-
44
- def subscribe
45
- super if defined?(super)
46
-
47
- on("Page.frameAttached") do |params|
48
- @frames[params["frameId"]] = { "parent_id" => params["parentFrameId"] }
49
- end
50
-
51
- on("Page.frameStartedLoading") do |params|
52
- @waiting_frames << params["frameId"]
53
- @event.reset
54
- end
55
-
56
- on("Page.frameNavigated") do |params|
57
- id = params["frame"]["id"]
58
- if frame = @frames[id]
59
- frame.merge!(params["frame"].select { |k, _| k == "name" || k == "url" })
60
- end
61
- end
62
-
63
- on("Page.frameScheduledNavigation") do |params|
64
- @waiting_frames << params["frameId"]
65
- @event.reset
66
- end
67
-
68
- on("Page.frameStoppedLoading") do |params|
69
- # `DOM.performSearch` doesn't work without getting #document node first.
70
- # It returns node with nodeId 1 and nodeType 9 from which descend the
71
- # tree and we save it in a variable because if we call that again root
72
- # node will change the id and all subsequent nodes have to change id too.
73
- if params["frameId"] == @frame_id
74
- @event.set if @waiting_frames.empty?
75
- @document_id = get_document_id
76
- end
77
-
78
- if @waiting_frames.include?(params["frameId"])
79
- @waiting_frames.delete(params["frameId"])
80
- @event.set if @waiting_frames.empty?
81
- end
82
- end
83
-
84
- on("Network.requestWillBeSent") do |params|
85
- if params["frameId"] == @frame_id
86
- # Possible types:
87
- # Document, Stylesheet, Image, Media, Font, Script, TextTrack, XHR,
88
- # Fetch, EventSource, WebSocket, Manifest, SignedExchange, Ping,
89
- # CSPViolationReport, Other
90
- @event.reset if params["type"] == "Document"
91
- end
92
- end
93
-
94
- on("Runtime.executionContextCreated") do |params|
95
- context_id = params.dig("context", "id")
96
- @execution_context_id ||= context_id
97
-
98
- frame_id = params.dig("context", "auxData", "frameId")
99
- @frame_id ||= frame_id # Remember the very first frame since it's the main one
100
-
101
- if @frames[frame_id]
102
- @frames[frame_id].merge!("execution_context_id" => context_id)
103
- else
104
- @frames[frame_id] = { "execution_context_id" => context_id }
105
- end
106
- end
107
-
108
- on("Runtime.executionContextDestroyed") do |params|
109
- context_id = params["executionContextId"]
110
-
111
- if @execution_context_id == context_id
112
- @execution_context_id = nil
113
- end
114
-
115
- _id, frame = @frames.find { |_, p| p["execution_context_id"] == context_id }
116
- frame["execution_context_id"] = nil if frame
117
- end
118
-
119
- on("Runtime.executionContextsCleared") do
120
- # If we didn't have time to set context id at the beginning we have
121
- # to set lock and release it when we set something.
122
- @execution_context_id = nil
123
- end
124
- end
125
-
126
- def current_execution_context_id
127
- if @frame_stack.empty?
128
- @execution_context_id
129
- else
130
- @frames.dig(@frame_stack.last, "execution_context_id")
131
- end
132
- end
133
- end
134
- end
135
- end