Class: Ferrum::Node

Inherits:
Object
  • Object
show all
Defined in:
lib/ferrum/node.rb

Overview

Note:

Node identity is tied to the target it was found on; a Node fetched before a navigation cannot be used afterwards.

Represents a DOM node (an element or a text node) found on a Page or within a Frame. Provides methods to inspect it (text, property, attribute), interact with it (click, focus, type, select) and search within it (at_css, at_xpath, css, xpath).

Constant Summary collapse

MOVING_WAIT_DELAY =
ENV.fetch("FERRUM_NODE_MOVING_WAIT", 0.01).to_f
MOVING_WAIT_ATTEMPTS =
ENV.fetch("FERRUM_NODE_MOVING_ATTEMPTS", 50).to_i

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(frame, target_id, description, object_id: nil, node_id: nil) ⇒ Node

Returns a new instance of Node.



19
20
21
22
23
24
25
26
# File 'lib/ferrum/node.rb', line 19

def initialize(frame, target_id, description, object_id: nil, node_id: nil)
  @page = frame.page
  @target_id = target_id
  @description = description
  @tag_name = description["nodeName"].downcase
  @object_id = object_id
  @node_id = node_id
end

Instance Attribute Details

#descriptionObject (readonly)

Returns the value of attribute description.



17
18
19
# File 'lib/ferrum/node.rb', line 17

def description
  @description
end

#pageObject (readonly)

Returns the value of attribute page.



17
18
19
# File 'lib/ferrum/node.rb', line 17

def page
  @page
end

#tag_nameObject (readonly)

Returns the value of attribute tag_name.



17
18
19
# File 'lib/ferrum/node.rb', line 17

def tag_name
  @tag_name
end

#target_idObject (readonly)

Returns the value of attribute target_id.



17
18
19
# File 'lib/ferrum/node.rb', line 17

def target_id
  @target_id
end

Instance Method Details

#==(other) ⇒ Boolean

Two nodes are equal when they belong to the same target and share the same backend node id.

Parameters:

  • other (Object)

Returns:

  • (Boolean)


442
443
444
445
446
447
448
449
# File 'lib/ferrum/node.rb', line 442

def ==(other)
  return false unless other.is_a?(Node)

  # We compare backendNodeId because once nodeId is sent to frontend backend
  # never returns same nodeId sending 0. In other words frontend is
  # responsible for keeping track of node ids.
  target_id == other.target_id && description["backendNodeId"] == other.description["backendNodeId"]
end

#at_css(selector) ⇒ Node?

Finds a node by CSS selector, scoped to search within this node. Runs querySelector within this node.

Examples:

page.at_css("form").at_css("input[name='q']") # => Node

Parameters:

  • selector (String)

Returns:



266
267
268
# File 'lib/ferrum/node.rb', line 266

def at_css(selector)
  page.at_css(selector, within: self)
end

#at_xpath(selector) ⇒ Node?

Finds a node by xpath, scoped to search within this node. Runs document.evaluate within this node.

Examples:

page.at_xpath("//iframe").at_xpath(".//a") # => Node

Parameters:

  • selector (String)

Returns:



251
252
253
# File 'lib/ferrum/node.rb', line 251

def at_xpath(selector)
  page.at_xpath(selector, within: self)
end

#attribute(name) ⇒ String?

Returns the value of the given HTML attribute, i.e. getAttribute(name). Unlike #property, it reads the attribute as defined in markup rather than the live DOM property.

Examples:

page.at_css("input").attribute("value") # => "Foo"

Parameters:

  • name (String)

Returns:

  • (String, nil)


354
355
356
# File 'lib/ferrum/node.rb', line 354

def attribute(name)
  evaluate("this.getAttribute('#{name}')")
end

#axnodeAccessibility::AXNode?

Returns the computed accessibility node for the element, or nil if the element is ignored by the accessibility tree.

Returns:



496
497
498
# File 'lib/ferrum/node.rb', line 496

def axnode
  page.accessibility.node_for(self)
end

#blurself

Removes focus from the node.

Returns:

  • (self)


137
138
139
# File 'lib/ferrum/node.rb', line 137

def blur
  tap { evaluate("this.blur()") }
end

#click(mode: :left, keys: [], offset: {}, delay: 0) ⇒ Object

mode: (:left | :right | :double) keys: (:alt, (:ctrl | :control), (:meta | :command), :shift) offset: { :x, :y, :position (:top | :center) }



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
# File 'lib/ferrum/node.rb', line 160

def click(mode: :left, keys: [], offset: {}, delay: 0)
  x, y = find_position(**offset)
  modifiers = page.keyboard.modifiers(keys)

  # `:right` and `:double` pass `wait: 0` to preserve the historical
  # no-network-wait default of `Mouse#up` and `Mouse#down`
  case mode
  when :right
    page.mouse.click(x:, y:, modifiers:, delay:, button: :right, wait: 0)
  when :double
    page.mouse.click(x:, y:, modifiers:, delay:, count: 2, wait: 0)
  when :left
    page.mouse.click(x:, y:, modifiers:, delay:)
  end

  self
end

#computed_styleObject

Returns a hash of the computed styles for the node



486
487
488
489
490
# File 'lib/ferrum/node.rb', line 486

def computed_style
  page
    .command("CSS.getComputedStyleForNode", nodeId: node_id)["computedStyle"]
    .each_with_object({}) { |style, memo| memo.merge!(style["name"] => style["value"]) }
end

#css(selector) ⇒ Array<Node>

Finds nodes by CSS selector, scoped to search within this node. Runs querySelectorAll within this node.

Examples:

page.at_css("ul").css("li") # => [Node]

Parameters:

  • selector (String)

Returns:



296
297
298
# File 'lib/ferrum/node.rb', line 296

def css(selector)
  page.css(selector, within: self)
end

#evaluate(expression) ⇒ Object

Evaluates the given JavaScript expression with this bound to the node.

Examples:

page.at_css("input").evaluate("this.value")

Parameters:

  • expression (String)

Returns:

  • (Object)


430
431
432
# File 'lib/ferrum/node.rb', line 430

def evaluate(expression)
  page.evaluate_on(node: self, expression: expression)
end

#exists?Boolean

Whether the node still exists in the DOM.

Returns:

  • (Boolean)


515
516
517
518
519
520
# File 'lib/ferrum/node.rb', line 515

def exists?
  page.command("DOM.resolveNode", nodeId: node_id)
  true
rescue Ferrum::NodeNotFoundError
  false
end

#find_position(x: nil, y: nil, position: :top) ⇒ (Integer, Integer)

Finds the x, y coordinates to click or hover on the node.

Parameters:

  • x (Integer, nil) (defaults to: nil)

    Horizontal offset from the reference point.

  • y (Integer, nil) (defaults to: nil)

    Vertical offset from the reference point.

  • position (Symbol) (defaults to: :top)

    :top to offset from the node's top-left corner, :center to offset from its center.

Returns:

  • ((Integer, Integer))


475
476
477
478
479
480
481
482
483
# File 'lib/ferrum/node.rb', line 475

def find_position(x: nil, y: nil, position: :top)
  points = wait_for_stop_moving.map { |q| to_points(q) }.first
  get_position(points, x, y, position)
rescue CoordinatesNotFoundError
  x, y = bounding_rect_coordinates
  raise if x.zero? && y.zero?

  [x, y]
end

#focusself

Focuses the node.

Examples:

input = page.at_css("input[name='q']")
input.focus

Returns:

  • (self)


82
83
84
# File 'lib/ferrum/node.rb', line 82

def focus
  tap { page.command("DOM.focus", slowmoable: true, nodeId: node_id) }
end

#focusable?Boolean

Whether the node can receive focus. Attempts to #focus the node to find out.

Returns:

  • (Boolean)


90
91
92
93
94
95
# File 'lib/ferrum/node.rb', line 90

def focusable?
  focus
  true
rescue BrowserError => e
  e.message == "Element is not focusable" ? false : raise
end

#frameFrame?

The Frame this node belongs to. Keep using finder methods (at_css, at_xpath, etc.) on it to search within that frame, e.g. inside an iframe.

Examples:

frame = page.at_xpath("//iframe").frame # => Frame
frame.at_css("//a[text() = 'Log in']") # => Node

Returns:



69
70
71
# File 'lib/ferrum/node.rb', line 69

def frame
  page.frame_by(id: frame_id)
end

#frame_idString

The id of the frame this node belongs to.

Returns:

  • (String)


54
55
56
# File 'lib/ferrum/node.rb', line 54

def frame_id
  description["frameId"]
end

#hoverObject

Not currently implemented.

Raises:



181
182
183
# File 'lib/ferrum/node.rb', line 181

def hover
  raise NotImplementedError
end

#in_viewport?(of: nil) ⇒ Boolean

Whether the node's bounding rect is fully within the viewport (or, when of: is given, within that scoping element's bounds).

Parameters:

  • of (Node, nil) (defaults to: nil)

    An element to use as the visible bounds instead of the window.

Returns:

  • (Boolean)


204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
# File 'lib/ferrum/node.rb', line 204

def in_viewport?(of: nil)
  function = <<~JS
    function(element, scope) {
      const rect = element.getBoundingClientRect();
      const [height, width] = scope
        ? [scope.offsetHeight, scope.offsetWidth]
        : [window.innerHeight, window.innerWidth];
      return rect.top >= 0 &&
       rect.left >= 0 &&
       rect.bottom <= height &&
       rect.right <= width;
    }
  JS
  page.evaluate_func(function, self, of)
end

#inner_textObject

FIXME: clear API for text and inner_text



313
314
315
# File 'lib/ferrum/node.rb', line 313

def inner_text
  evaluate("this.innerText")
end

#inspectString

A developer-friendly string representation of the node.

Returns:

  • (String)


456
457
458
# File 'lib/ferrum/node.rb', line 456

def inspect
  %(#<#{self.class} @target_id=#{@target_id.inspect} @node_id=#{@node_id} @description=#{@description.inspect}>)
end

#moving?(delay: MOVING_WAIT_DELAY) ⇒ Boolean

Checks whether the node's position has stopped changing, by comparing two content-quad snapshots taken delay seconds apart.

Parameters:

  • delay (Float) (defaults to: MOVING_WAIT_DELAY)

    Seconds to wait between the two position checks.

Returns:

  • (Boolean)


127
128
129
130
# File 'lib/ferrum/node.rb', line 127

def moving?(delay: MOVING_WAIT_DELAY)
  previous, current = content_quads_with(delay: delay)
  previous == current
end

#node?Boolean

Whether this is an element node, as opposed to e.g. a text node.

Returns:

  • (Boolean)


45
46
47
# File 'lib/ferrum/node.rb', line 45

def node?
  description["nodeType"] == 1 # nodeType: 3, nodeName: "#text" e.g.
end

#node_idObject

Frontend node id is resolved lazily, on first actual need (focus, click, scroll_into_view, etc.) We can try to subscribe to DOM.childNodeRemoved and DOM.childNodeInserted in the future to keep track of nodes.



31
32
33
34
35
36
37
38
39
40
# File 'lib/ferrum/node.rb', line 31

def node_id
  @node_id ||= begin
    id = page.command("DOM.requestNode", objectId: @object_id)["nodeId"]
    raise NodeNotFoundError, "node is not trackable" if id.zero?

    id
  rescue NoExecutionContextError
    raise NodeNotFoundError, "node is not trackable"
  end
end

#property(name) ⇒ Object Also known as: []

Returns the given JavaScript property of the node.

Examples:

page.at_css("input").property("value") # => "Foo"

Parameters:

  • name (String)

Returns:

  • (Object)


337
338
339
# File 'lib/ferrum/node.rb', line 337

def property(name)
  evaluate("this['#{name}']")
end

#removeHash{String => Object}

Removes the node from the DOM.

Examples:

page.at_css("#ad").remove

Returns:

  • (Hash{String => Object})


508
509
510
# File 'lib/ferrum/node.rb', line 508

def remove
  page.command("DOM.removeNode", nodeId: node_id)
end

#scroll_into_viewself

Scrolls the node into view if it is not already visible.

Examples:

page.at_css("#footer").scroll_into_view

Returns:

  • (self)


193
194
195
# File 'lib/ferrum/node.rb', line 193

def scroll_into_view
  tap { page.command("DOM.scrollIntoViewIfNeeded", nodeId: node_id) }
end

#select(*values, by: :value) ⇒ self

(chainable) Selects options of a select element by the given attribute.

Examples:

page.at_xpath("//*[select]").select(["1"]) # => Node (select)
page.at_xpath("//*[select]").select(["text"], by: :text) # => Node (select)

Accepts a string, multiple strings or an array of strings:

page.at_xpath("//*[select]").select("1")
page.at_xpath("//*[select]").select("1", "2")
page.at_xpath("//*[select]").select(["1", "2"])

Parameters:

  • values (Array<String>)

    The value(s) to select. Accepts a string, multiple strings, or an array of strings.

  • by (Symbol) (defaults to: :value)

    The option attribute to match values against, e.g. :value or :text.

Returns:

  • (self)


398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
# File 'lib/ferrum/node.rb', line 398

def select(*values, by: :value)
  tap do
    function = <<~JS
      function(element, values, by) {
        if (element.nodeName.toLowerCase() !== 'select') {
          throw new Error('Element is not a <select> element.');
        }
        const options = Array.from(element.options);
        element.value = undefined;
        for (const option of options) {
          option.selected = values.some((value) => option[by] === value);
          if (option.selected && !element.multiple) break;
        }
        element.dispatchEvent(new Event('input', { bubbles: true }));
        element.dispatchEvent(new Event('change', { bubbles: true }));
      }
    JS
    page.evaluate_func(function, self, values.flatten, by, on: self)
  end
end

#select_file(value) ⇒ Hash{String => Object}

Sets files on a file input node.

Examples:

page.at_css("input[type=file]").select_file("/path/to/file.png")

Parameters:

  • value (String, Array<String>)

    Path or paths to the file(s) to upload.

Returns:

  • (Hash{String => Object})


231
232
233
234
235
236
237
238
# File 'lib/ferrum/node.rb', line 231

def select_file(value)
  page.command(
    "DOM.setFileInputFiles",
    slowmoable: true,
    backendNodeId: description["backendNodeId"],
    files: Array(value)
  )
end

#selectedArray<Node>

Returns the selected option nodes of a select element.

Returns:



363
364
365
366
367
368
369
370
371
372
373
# File 'lib/ferrum/node.rb', line 363

def selected
  function = <<~JS
    function(element) {
      if (element.nodeName.toLowerCase() !== 'select') {
        throw new Error('Element is not a <select> element.');
      }
      return Array.from(element).filter(option => option.selected);
    }
  JS
  page.evaluate_func(function, self, on: self)
end

#textString

The node's text content, i.e. textContent.

Examples:

page.at_css("a > h3").text # => "rubycdp/ferrum: Ruby Chrome/Chromium driver - GitHub"

Returns:

  • (String)


308
309
310
# File 'lib/ferrum/node.rb', line 308

def text
  evaluate("this.textContent")
end

#type(*keys) ⇒ self

Sends keystrokes to the currently focused element via the page's keyboard. Typically chained after #focus or click.

Examples:

input.focus.type("Input")

Parameters:

  • keys (Array<String, Symbol, (Symbol, String)>)

    The keys to type, e.g. "Input", [:Shift, "s"], "tring".

Returns:

  • (self)


153
154
155
# File 'lib/ferrum/node.rb', line 153

def type(*keys)
  tap { page.keyboard.type(*keys) }
end

#valueObject

The node's value property. Useful for form elements such as input, select and textarea.

Returns:

  • (Object)


323
324
325
# File 'lib/ferrum/node.rb', line 323

def value
  evaluate("this.value")
end

#wait_for_stop_moving(delay: MOVING_WAIT_DELAY, attempts: MOVING_WAIT_ATTEMPTS) ⇒ Array

Waits until the node's position stops changing, retrying up to attempts times. Raises Ferrum::NodeMovingError if the node is still moving after the last attempt.

Parameters:

  • delay (Float) (defaults to: MOVING_WAIT_DELAY)

    Seconds to wait between two position checks.

  • attempts (Integer) (defaults to: MOVING_WAIT_ATTEMPTS)

    Maximum number of attempts before raising.

Returns:

  • (Array)

    The content quads of the node once it has stopped moving.



111
112
113
114
115
116
117
118
# File 'lib/ferrum/node.rb', line 111

def wait_for_stop_moving(delay: MOVING_WAIT_DELAY, attempts: MOVING_WAIT_ATTEMPTS)
  Utils::Attempt.with_retry(errors: NodeMovingError, max: attempts, wait: 0) do
    previous, current = content_quads_with(delay: delay)
    raise NodeMovingError.new(self, previous, current) if previous != current

    current
  end
end

#xpath(selector) ⇒ Array<Node>

Finds nodes by xpath, scoped to search within this node. Runs document.evaluate within this node.

Examples:

page.at_css("ul").xpath(".//li") # => [Node]

Parameters:

  • selector (String)

Returns:



281
282
283
# File 'lib/ferrum/node.rb', line 281

def xpath(selector)
  page.xpath(selector, within: self)
end