ferrum 0.5 → 0.6

Sign up to get free protection for your applications and to get access to all the features.
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