Class: Ferrum::Node
- Inherits:
-
Object
- Object
- Ferrum::Node
- Defined in:
- lib/ferrum/node.rb
Overview
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
-
#description ⇒ Object
readonly
Returns the value of attribute description.
-
#page ⇒ Object
readonly
Returns the value of attribute page.
-
#tag_name ⇒ Object
readonly
Returns the value of attribute tag_name.
-
#target_id ⇒ Object
readonly
Returns the value of attribute target_id.
Instance Method Summary collapse
-
#==(other) ⇒ Boolean
Two nodes are equal when they belong to the same target and share the same backend node id.
-
#at_css(selector) ⇒ Node?
Finds a node by CSS selector, scoped to search within this node.
-
#at_xpath(selector) ⇒ Node?
Finds a node by xpath, scoped to search within this node.
-
#attribute(name) ⇒ String?
Returns the value of the given HTML attribute, i.e.
-
#axnode ⇒ Accessibility::AXNode?
Returns the computed accessibility node for the element, or nil if the element is ignored by the accessibility tree.
-
#blur ⇒ self
Removes focus from the node.
-
#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) }.
-
#computed_style ⇒ Object
Returns a hash of the computed styles for the node.
-
#css(selector) ⇒ Array<Node>
Finds nodes by CSS selector, scoped to search within this node.
-
#evaluate(expression) ⇒ Object
Evaluates the given JavaScript expression with
thisbound to the node. -
#exists? ⇒ Boolean
Whether the node still exists in the DOM.
-
#find_position(x: nil, y: nil, position: :top) ⇒ (Integer, Integer)
Finds the x, y coordinates to click or hover on the node.
-
#focus ⇒ self
Focuses the node.
-
#focusable? ⇒ Boolean
Whether the node can receive focus.
-
#frame ⇒ Frame?
The Frame this node belongs to.
-
#frame_id ⇒ String
The id of the frame this node belongs to.
-
#hover ⇒ Object
Not currently implemented.
-
#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). -
#initialize(frame, target_id, description, object_id: nil, node_id: nil) ⇒ Node
constructor
A new instance of Node.
-
#inner_text ⇒ Object
FIXME: clear API for text and inner_text.
-
#inspect ⇒ String
A developer-friendly string representation of the node.
-
#moving?(delay: MOVING_WAIT_DELAY) ⇒ Boolean
Checks whether the node's position has stopped changing, by comparing two content-quad snapshots taken
delayseconds apart. -
#node? ⇒ Boolean
Whether this is an element node, as opposed to e.g.
-
#node_id ⇒ Object
Frontend node id is resolved lazily, on first actual need (focus, click, scroll_into_view, etc.) We can try to subscribe to
DOM.childNodeRemovedandDOM.childNodeInsertedin the future to keep track of nodes. -
#property(name) ⇒ Object
(also: #[])
Returns the given JavaScript property of the node.
-
#remove ⇒ Hash{String => Object}
Removes the node from the DOM.
-
#scroll_into_view ⇒ self
Scrolls the node into view if it is not already visible.
-
#select(*values, by: :value) ⇒ self
(chainable) Selects options of a
selectelement by the given attribute. -
#select_file(value) ⇒ Hash{String => Object}
Sets files on a file input node.
-
#selected ⇒ Array<Node>
Returns the selected
optionnodes of aselectelement. -
#text ⇒ String
The node's text content, i.e.
-
#type(*keys) ⇒ self
Sends keystrokes to the currently focused element via the page's keyboard.
-
#value ⇒ Object
The node's
valueproperty. -
#wait_for_stop_moving(delay: MOVING_WAIT_DELAY, attempts: MOVING_WAIT_ATTEMPTS) ⇒ Array
Waits until the node's position stops changing, retrying up to
attemptstimes. -
#xpath(selector) ⇒ Array<Node>
Finds nodes by xpath, scoped to search within this node.
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
#description ⇒ Object (readonly)
Returns the value of attribute description.
17 18 19 |
# File 'lib/ferrum/node.rb', line 17 def description @description end |
#page ⇒ Object (readonly)
Returns the value of attribute page.
17 18 19 |
# File 'lib/ferrum/node.rb', line 17 def page @page end |
#tag_name ⇒ Object (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_id ⇒ Object (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.
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.
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.
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.
354 355 356 |
# File 'lib/ferrum/node.rb', line 354 def attribute(name) evaluate("this.getAttribute('#{name}')") end |
#axnode ⇒ Accessibility::AXNode?
Returns the computed accessibility node for the element, or nil if the element is ignored by the accessibility tree.
496 497 498 |
# File 'lib/ferrum/node.rb', line 496 def axnode page.accessibility.node_for(self) end |
#blur ⇒ self
Removes focus from the node.
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_style ⇒ Object
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.
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.
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.
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.
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 |
#focus ⇒ self
Focuses the node.
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.
90 91 92 93 94 95 |
# File 'lib/ferrum/node.rb', line 90 def focusable? focus true rescue BrowserError => e e. == "Element is not focusable" ? false : raise end |
#frame ⇒ Frame?
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.
69 70 71 |
# File 'lib/ferrum/node.rb', line 69 def frame page.frame_by(id: frame_id) end |
#frame_id ⇒ String
The id of the frame this node belongs to.
54 55 56 |
# File 'lib/ferrum/node.rb', line 54 def frame_id description["frameId"] end |
#hover ⇒ Object
Not currently implemented.
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).
204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 |
# File 'lib/ferrum/node.rb', line 204 def (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_text ⇒ Object
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 |
#inspect ⇒ String
A developer-friendly string representation of the node.
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.
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.
45 46 47 |
# File 'lib/ferrum/node.rb', line 45 def node? description["nodeType"] == 1 # nodeType: 3, nodeName: "#text" e.g. end |
#node_id ⇒ Object
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.
337 338 339 |
# File 'lib/ferrum/node.rb', line 337 def property(name) evaluate("this['#{name}']") end |
#remove ⇒ Hash{String => Object}
Removes the node from the DOM.
508 509 510 |
# File 'lib/ferrum/node.rb', line 508 def remove page.command("DOM.removeNode", nodeId: node_id) end |
#scroll_into_view ⇒ self
Scrolls the node into view if it is not already visible.
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.
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.
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 |
#selected ⇒ Array<Node>
Returns the selected option nodes of a select element.
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 |
#text ⇒ String
The node's text content, i.e. textContent.
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.
153 154 155 |
# File 'lib/ferrum/node.rb', line 153 def type(*keys) tap { page.keyboard.type(*keys) } end |
#value ⇒ Object
The node's value property. Useful for form elements such as input,
select and textarea.
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.
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.
281 282 283 |
# File 'lib/ferrum/node.rb', line 281 def xpath(selector) page.xpath(selector, within: self) end |