ferrum 0.12 → 0.13

Sign up to get free protection for your applications and to get access to all the features.
@@ -20,10 +20,59 @@
20
20
  module Ferrum
21
21
  class Frame
22
22
  module DOM
23
+ SCRIPT_SRC_TAG = <<~JS
24
+ const script = document.createElement("script");
25
+ script.src = arguments[0];
26
+ script.type = arguments[1];
27
+ script.onload = arguments[2];
28
+ document.head.appendChild(script);
29
+ JS
30
+ SCRIPT_TEXT_TAG = <<~JS
31
+ const script = document.createElement("script");
32
+ script.text = arguments[0];
33
+ script.type = arguments[1];
34
+ document.head.appendChild(script);
35
+ arguments[2]();
36
+ JS
37
+ STYLE_TAG = <<~JS
38
+ const style = document.createElement("style");
39
+ style.type = "text/css";
40
+ style.appendChild(document.createTextNode(arguments[0]));
41
+ document.head.appendChild(style);
42
+ arguments[1]();
43
+ JS
44
+ LINK_TAG = <<~JS
45
+ const link = document.createElement("link");
46
+ link.rel = "stylesheet";
47
+ link.href = arguments[0];
48
+ link.onload = arguments[1];
49
+ document.head.appendChild(link);
50
+ JS
51
+
52
+ #
53
+ # Returns current top window `location href`.
54
+ #
55
+ # @return [String]
56
+ # The window's current URL.
57
+ #
58
+ # @example
59
+ # browser.go_to("https://google.com/")
60
+ # browser.current_url # => "https://www.google.com/"
61
+ #
23
62
  def current_url
24
63
  evaluate("window.top.location.href")
25
64
  end
26
65
 
66
+ #
67
+ # Returns current top window title.
68
+ #
69
+ # @return [String]
70
+ # The window's current title.
71
+ #
72
+ # @example
73
+ # browser.go_to("https://google.com/")
74
+ # browser.current_title # => "Google"
75
+ #
27
76
  def current_title
28
77
  evaluate("window.top.document.title")
29
78
  end
@@ -32,10 +81,36 @@ module Ferrum
32
81
  evaluate("document.doctype && new XMLSerializer().serializeToString(document.doctype)")
33
82
  end
34
83
 
84
+ #
85
+ # Returns current page's html.
86
+ #
87
+ # @return [String]
88
+ # The HTML source of the current page.
89
+ #
90
+ # @example
91
+ # browser.go_to("https://google.com/")
92
+ # browser.body # => '<html itemscope="" itemtype="http://schema.org/WebPage" lang="ru"><head>...
93
+ #
35
94
  def body
36
95
  evaluate("document.documentElement.outerHTML")
37
96
  end
38
97
 
98
+ #
99
+ # Finds nodes by using a XPath selector.
100
+ #
101
+ # @param [String] selector
102
+ # The XPath selector.
103
+ #
104
+ # @param [Node, nil] within
105
+ # The parent node to search within.
106
+ #
107
+ # @return [Array<Node>]
108
+ # The matching nodes.
109
+ #
110
+ # @example
111
+ # browser.go_to("https://github.com/")
112
+ # browser.xpath("//a[@aria-label='Issues you created']") # => [Node]
113
+ #
39
114
  def xpath(selector, within: nil)
40
115
  expr = <<~JS
41
116
  function(selector, within) {
@@ -54,6 +129,22 @@ module Ferrum
54
129
  evaluate_func(expr, selector, within)
55
130
  end
56
131
 
132
+ #
133
+ # Finds a node by using a XPath selector.
134
+ #
135
+ # @param [String] selector
136
+ # The XPath selector.
137
+ #
138
+ # @param [Node, nil] within
139
+ # The parent node to search within.
140
+ #
141
+ # @return [Node, nil]
142
+ # The matching node.
143
+ #
144
+ # @example
145
+ # browser.go_to("https://github.com/")
146
+ # browser.at_xpath("//a[@aria-label='Issues you created']") # => Node
147
+ #
57
148
  def at_xpath(selector, within: nil)
58
149
  expr = <<~JS
59
150
  function(selector, within) {
@@ -65,6 +156,22 @@ module Ferrum
65
156
  evaluate_func(expr, selector, within)
66
157
  end
67
158
 
159
+ #
160
+ # Finds nodes by using a CSS path selector.
161
+ #
162
+ # @param [String] selector
163
+ # The CSS path selector.
164
+ #
165
+ # @param [Node, nil] within
166
+ # The parent node to search within.
167
+ #
168
+ # @return [Array<Node>]
169
+ # The matching nodes.
170
+ #
171
+ # @example
172
+ # browser.go_to("https://github.com/")
173
+ # browser.css("a[aria-label='Issues you created']") # => [Node]
174
+ #
68
175
  def css(selector, within: nil)
69
176
  expr = <<~JS
70
177
  function(selector, within) {
@@ -76,6 +183,22 @@ module Ferrum
76
183
  evaluate_func(expr, selector, within)
77
184
  end
78
185
 
186
+ #
187
+ # Finds a node by using a CSS path selector.
188
+ #
189
+ # @param [String] selector
190
+ # The CSS path selector.
191
+ #
192
+ # @param [Node, nil] within
193
+ # The parent node to search within.
194
+ #
195
+ # @return [Node, nil]
196
+ # The matching node.
197
+ #
198
+ # @example
199
+ # browser.go_to("https://github.com/")
200
+ # browser.at_css("a[aria-label='Issues you created']") # => Node
201
+ #
79
202
  def at_css(selector, within: nil)
80
203
  expr = <<~JS
81
204
  function(selector, within) {
@@ -86,6 +209,60 @@ module Ferrum
86
209
 
87
210
  evaluate_func(expr, selector, within)
88
211
  end
212
+
213
+ #
214
+ # Adds a `<script>` tag to the document.
215
+ #
216
+ # @param [String, nil] url
217
+ #
218
+ # @param [String, nil] path
219
+ #
220
+ # @param [String, nil] content
221
+ #
222
+ # @param [String] type
223
+ #
224
+ # @example
225
+ # browser.add_script_tag(url: "http://example.com/stylesheet.css") # => true
226
+ #
227
+ def add_script_tag(url: nil, path: nil, content: nil, type: "text/javascript")
228
+ expr, *args = if url
229
+ [SCRIPT_SRC_TAG, url, type]
230
+ elsif path || content
231
+ if path
232
+ content = File.read(path)
233
+ content += "\n//# sourceURL=#{path}"
234
+ end
235
+ [SCRIPT_TEXT_TAG, content, type]
236
+ end
237
+
238
+ evaluate_async(expr, @page.timeout, *args)
239
+ end
240
+
241
+ #
242
+ # Adds a `<style>` tag to the document.
243
+ #
244
+ # @param [String, nil] url
245
+ #
246
+ # @param [String, nil] path
247
+ #
248
+ # @param [String, nil] content
249
+ #
250
+ # @example
251
+ # browser.add_style_tag(content: "h1 { font-size: 40px; }") # => true
252
+ #
253
+ def add_style_tag(url: nil, path: nil, content: nil)
254
+ expr, *args = if url
255
+ [LINK_TAG, url]
256
+ elsif path || content
257
+ if path
258
+ content = File.read(path)
259
+ content += "\n//# sourceURL=#{path}"
260
+ end
261
+ [STYLE_TAG, content]
262
+ end
263
+
264
+ evaluate_async(expr, @page.timeout, *args)
265
+ end
89
266
  end
90
267
  end
91
268
  end
@@ -16,40 +16,38 @@ module Ferrum
16
16
  INTERMITTENT_ATTEMPTS = ENV.fetch("FERRUM_INTERMITTENT_ATTEMPTS", 6).to_i
17
17
  INTERMITTENT_SLEEP = ENV.fetch("FERRUM_INTERMITTENT_SLEEP", 0.1).to_f
18
18
 
19
- SCRIPT_SRC_TAG = <<~JS
20
- const script = document.createElement("script");
21
- script.src = arguments[0];
22
- script.type = arguments[1];
23
- script.onload = arguments[2];
24
- document.head.appendChild(script);
25
- JS
26
- SCRIPT_TEXT_TAG = <<~JS
27
- const script = document.createElement("script");
28
- script.text = arguments[0];
29
- script.type = arguments[1];
30
- document.head.appendChild(script);
31
- arguments[2]();
32
- JS
33
- STYLE_TAG = <<~JS
34
- const style = document.createElement("style");
35
- style.type = "text/css";
36
- style.appendChild(document.createTextNode(arguments[0]));
37
- document.head.appendChild(style);
38
- arguments[1]();
39
- JS
40
- LINK_TAG = <<~JS
41
- const link = document.createElement("link");
42
- link.rel = "stylesheet";
43
- link.href = arguments[0];
44
- link.onload = arguments[1];
45
- document.head.appendChild(link);
46
- JS
47
-
19
+ #
20
+ # Evaluate and return result for given JS expression.
21
+ #
22
+ # @param [String] expression
23
+ # The JavaScript to evaluate.
24
+ #
25
+ # @param [Array] args
26
+ # Additional arguments to pass to the JavaScript code.
27
+ #
28
+ # @example
29
+ # browser.evaluate("[window.scrollX, window.scrollY]")
30
+ #
48
31
  def evaluate(expression, *args)
49
32
  expression = format("function() { return %s }", expression)
50
33
  call(expression: expression, arguments: args)
51
34
  end
52
35
 
36
+ #
37
+ # Evaluate asynchronous expression and return result.
38
+ #
39
+ # @param [String] expression
40
+ # The JavaScript to evaluate.
41
+ #
42
+ # @param [Integer] wait
43
+ # How long we should wait for Promise to resolve or reject.
44
+ #
45
+ # @param [Array] args
46
+ # Additional arguments to pass to the JavaScript code.
47
+ #
48
+ # @example
49
+ # browser.evaluate_async(%(arguments[0]({foo: "bar"})), 5) # => { "foo" => "bar" }
50
+ #
53
51
  def evaluate_async(expression, wait, *args)
54
52
  template = <<~JS
55
53
  function() {
@@ -70,6 +68,18 @@ module Ferrum
70
68
  call(expression: expression, arguments: args, awaitPromise: true)
71
69
  end
72
70
 
71
+ #
72
+ # Execute expression. Doesn't return the result.
73
+ #
74
+ # @param [String] expression
75
+ # The JavaScript to evaluate.
76
+ #
77
+ # @param [Array] args
78
+ # Additional arguments to pass to the JavaScript code.
79
+ #
80
+ # @example
81
+ # browser.execute(%(1 + 1)) # => true
82
+ #
73
83
  def execute(expression, *args)
74
84
  expression = format("function() { %s }", expression)
75
85
  call(expression: expression, arguments: args, handle: false, returnByValue: true)
@@ -87,42 +97,12 @@ module Ferrum
87
97
  call(expression: expression, on: node, wait: wait, **options)
88
98
  end
89
99
 
90
- def add_script_tag(url: nil, path: nil, content: nil, type: "text/javascript")
91
- expr, *args = if url
92
- [SCRIPT_SRC_TAG, url, type]
93
- elsif path || content
94
- if path
95
- content = File.read(path)
96
- content += "\n//# sourceURL=#{path}"
97
- end
98
- [SCRIPT_TEXT_TAG, content, type]
99
- end
100
-
101
- evaluate_async(expr, @page.timeout, *args)
102
- end
103
-
104
- def add_style_tag(url: nil, path: nil, content: nil)
105
- expr, *args = if url
106
- [LINK_TAG, url]
107
- elsif path || content
108
- if path
109
- content = File.read(path)
110
- content += "\n//# sourceURL=#{path}"
111
- end
112
- [STYLE_TAG, content]
113
- end
114
-
115
- evaluate_async(expr, @page.timeout, *args)
116
- end
117
-
118
100
  private
119
101
 
120
102
  def call(expression:, arguments: [], on: nil, wait: 0, handle: true, **options)
121
103
  errors = [NodeNotFoundError, NoExecutionContextError]
122
- sleep = INTERMITTENT_SLEEP
123
- attempts = INTERMITTENT_ATTEMPTS
124
104
 
125
- Utils::Attempt.with_retry(errors: errors, max: attempts, wait: sleep) do
105
+ Utils::Attempt.with_retry(errors: errors, max: INTERMITTENT_ATTEMPTS, wait: INTERMITTENT_SLEEP) do
126
106
  params = options.dup
127
107
 
128
108
  if on
@@ -132,7 +112,7 @@ module Ferrum
132
112
  end
133
113
 
134
114
  if params[:executionContextId].nil? && params[:objectId].nil?
135
- params = params.merge(executionContextId: execution_id)
115
+ params = params.merge(executionContextId: execution_id!)
136
116
  end
137
117
 
138
118
  response = @page.command("Runtime.callFunctionOn",
data/lib/ferrum/frame.rb CHANGED
@@ -14,8 +14,30 @@ module Ferrum
14
14
  stopped_loading
15
15
  ].freeze
16
16
 
17
- attr_accessor :id, :name
18
- attr_reader :page, :parent_id, :state
17
+ # The Frame's unique id.
18
+ #
19
+ # @return [String]
20
+ attr_accessor :id
21
+
22
+ # If frame was given a name it should be here.
23
+ #
24
+ # @return [String, nil]
25
+ attr_accessor :name
26
+
27
+ # The page the frame belongs to.
28
+ #
29
+ # @return [Page]
30
+ attr_reader :page
31
+
32
+ # Parent frame id if this one is nested in another one.
33
+ #
34
+ # @return [String, nil]
35
+ attr_reader :parent_id
36
+
37
+ # One of the states frame's in.
38
+ #
39
+ # @return [:started_loading, :navigated, :stopped_loading, nil]
40
+ attr_reader :state
19
41
 
20
42
  def initialize(id, page, parent_id = nil)
21
43
  @id = id
@@ -30,18 +52,60 @@ module Ferrum
30
52
  @state = value
31
53
  end
32
54
 
55
+ #
56
+ # Returns current frame's `location.href`.
57
+ #
58
+ # @return [String]
59
+ #
60
+ # @example
61
+ # browser.go_to("https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe")
62
+ # frame = browser.frames[1]
63
+ # frame.url # => https://interactive-examples.mdn.mozilla.net/pages/tabbed/iframe.html
64
+ #
33
65
  def url
34
66
  evaluate("document.location.href")
35
67
  end
36
68
 
69
+ #
70
+ # Returns current frame's title.
71
+ #
72
+ # @return [String]
73
+ #
74
+ # @example
75
+ # browser.go_to("https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe")
76
+ # frame = browser.frames[1]
77
+ # frame.title # => HTML Demo: <iframe>
78
+ #
37
79
  def title
38
80
  evaluate("document.title")
39
81
  end
40
82
 
83
+ #
84
+ # If current frame is the main frame of the page (top of the tree).
85
+ #
86
+ # @return [Boolean]
87
+ #
88
+ # @example
89
+ # browser.go_to("https://www.w3schools.com/tags/tag_frame.asp")
90
+ # frame = browser.frame_by(id: "C09C4E4404314AAEAE85928EAC109A93")
91
+ # frame.main? # => false
92
+ #
41
93
  def main?
42
94
  @parent_id.nil?
43
95
  end
44
96
 
97
+ #
98
+ # Sets a content of a given frame.
99
+ #
100
+ # @param [String] html
101
+ #
102
+ # @example
103
+ # browser.go_to("https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe")
104
+ # frame = browser.frames[1]
105
+ # frame.body # <html lang="en"><head><style>body {transition: opacity ease-in 0.2s; }...
106
+ # frame.content = "<html><head></head><body><p>lol</p></body></html>"
107
+ # frame.body # => <html><head></head><body><p>lol</p></body></html>
108
+ #
45
109
  def content=(html)
46
110
  evaluate_async(%(
47
111
  document.open();
@@ -52,13 +116,36 @@ module Ferrum
52
116
  end
53
117
  alias set_content content=
54
118
 
55
- def execution_id
119
+ #
120
+ # Execution context id which is used by JS, each frame has it's own
121
+ # context in which JS evaluates. Locks for a page timeout and raises
122
+ # an error if an execution id hasn't been set yet, if id is set
123
+ # returns immediately.
124
+ #
125
+ # @return [Integer]
126
+ #
127
+ # @raise [NoExecutionContextError]
128
+ #
129
+ def execution_id!
56
130
  value = @execution_id.borrow(@page.timeout, &:itself)
57
131
  raise NoExecutionContextError if value.instance_of?(Object)
58
132
 
59
133
  value
60
134
  end
61
135
 
136
+ #
137
+ # Execution context id which is used by JS, each frame has it's own
138
+ # context in which JS evaluates.
139
+ #
140
+ # @return [Integer, nil]
141
+ #
142
+ def execution_id
143
+ value = @execution_id.value
144
+ return if value.instance_of?(Object)
145
+
146
+ value
147
+ end
148
+
62
149
  def execution_id=(value)
63
150
  if value.nil?
64
151
  @execution_id.try_take!
@@ -7,20 +7,48 @@ module Ferrum
7
7
  @headers = {}
8
8
  end
9
9
 
10
+ #
11
+ # Get all headers.
12
+ #
13
+ # @return [Hash{String => String}]
14
+ #
10
15
  def get
11
16
  @headers
12
17
  end
13
18
 
19
+ #
20
+ # Set given headers. Eventually clear all headers and set given ones.
21
+ #
22
+ # @param [Hash{String => String}] headers
23
+ # key-value pairs for example `"User-Agent" => "Browser"`.
24
+ #
25
+ # @return [true]
26
+ #
14
27
  def set(headers)
15
28
  clear
16
29
  add(headers)
17
30
  end
18
31
 
32
+ #
33
+ # Clear all headers.
34
+ #
35
+ # @return [true]
36
+ #
19
37
  def clear
20
38
  @headers = {}
21
39
  true
22
40
  end
23
41
 
42
+ #
43
+ # Adds given headers to already set ones.
44
+ #
45
+ # @param [Hash{String => String}] headers
46
+ # key-value pairs for example `"Referer" => "http://example.com"`.
47
+ #
48
+ # @param [Boolean] permanent
49
+ #
50
+ # @return [true]
51
+ #
24
52
  def add(headers, permanent: true)
25
53
  if headers["Referer"]
26
54
  @page.referrer = headers["Referer"]
@@ -30,19 +30,44 @@ module Ferrum
30
30
  @page = page
31
31
  end
32
32
 
33
+ #
34
+ # Dispatches a `keydown` event.
35
+ #
36
+ # @param [String, Symbol] key
37
+ # Name of the key, such as `"a"`, `:enter`, or `:backspace`.
38
+ #
39
+ # @return [self]
40
+ #
33
41
  def down(key)
34
- key = normalize_keys(Array(key))
42
+ key = normalize_keys(Array(key)).first
35
43
  type = key[:text] ? "keyDown" : "rawKeyDown"
36
44
  @page.command("Input.dispatchKeyEvent", slowmoable: true, type: type, **key)
37
45
  self
38
46
  end
39
47
 
48
+ #
49
+ # Dispatches a `keyup` event.
50
+ #
51
+ # @param [String, Symbol] key
52
+ # Name of the key, such as `"a"`, `:enter`, or `:backspace`.
53
+ #
54
+ # @return [self]
55
+ #
40
56
  def up(key)
41
- key = normalize_keys(Array(key))
57
+ key = normalize_keys(Array(key)).first
42
58
  @page.command("Input.dispatchKeyEvent", slowmoable: true, type: "keyUp", **key)
43
59
  self
44
60
  end
45
61
 
62
+ #
63
+ # Sends a keydown, keypress/input, and keyup event for each character in
64
+ # the text.
65
+ #
66
+ # @param [Array<String, Symbol, (Symbol, String)>] keys
67
+ # The text to type into a focused element, `[:Shift, "s"], "tring"`.
68
+ #
69
+ # @return [self]
70
+ #
46
71
  def type(*keys)
47
72
  keys = normalize_keys(Array(keys))
48
73
 
@@ -55,15 +80,27 @@ module Ferrum
55
80
  self
56
81
  end
57
82
 
83
+ #
84
+ # Returns bitfield for a given keys.
85
+ #
86
+ # @param [Array<:alt, :ctrl, :command, :shift>] keys
87
+ #
88
+ # @return [Integer]
89
+ #
58
90
  def modifiers(keys)
59
91
  keys.map { |k| MODIFIERS[k.to_s] }.compact.reduce(0, :|)
60
92
  end
61
93
 
62
94
  private
63
95
 
96
+ # TODO: Refactor it, and try to simplify complexity
97
+ # rubocop:disable Metrics/PerceivedComplexity
98
+ # rubocop:disable Metrics/CyclomaticComplexity
64
99
  def normalize_keys(keys, pressed_keys = [], memo = [])
65
100
  case keys
66
101
  when Array
102
+ raise ArgumentError, "empty keys passed" if keys.empty?
103
+
67
104
  pressed_keys.push([])
68
105
  memo += combine_strings(keys).map do |key|
69
106
  normalize_keys(key, pressed_keys, memo)
@@ -82,6 +119,8 @@ module Ferrum
82
119
  to_options(key)
83
120
  end
84
121
  when String
122
+ raise ArgumentError, "empty keys passed" if keys.empty?
123
+
85
124
  pressed = pressed_keys.flatten
86
125
  keys.each_char.map do |char|
87
126
  key = KEYS[char] || {}
@@ -102,8 +141,12 @@ module Ferrum
102
141
  modifiers + [to_options(key)]
103
142
  end.flatten
104
143
  end
144
+ else
145
+ raise ArgumentError, "unexpected argument"
105
146
  end
106
147
  end
148
+ # rubocop:enable Metrics/PerceivedComplexity
149
+ # rubocop:enable Metrics/CyclomaticComplexity
107
150
 
108
151
  def combine_strings(keys)
109
152
  keys