Class: Capybara::Lightpanda::Node

Inherits:
Driver::Node
  • Object
show all
Defined in:
lib/capybara/lightpanda/node.rb

Constant Summary collapse

MOVING_WAIT_DELAY =
ENV.fetch("LIGHTPANDA_NODE_MOVING_WAIT", 0.01).to_f
MOVING_WAIT_ATTEMPTS =
ENV.fetch("LIGHTPANDA_NODE_MOVING_ATTEMPTS", 50).to_i

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(driver, remote_object_id) ⇒ Node

Returns a new instance of Node.



11
12
13
14
# File 'lib/capybara/lightpanda/node.rb', line 11

def initialize(driver, remote_object_id)
  super
  @remote_object_id = remote_object_id
end

Instance Attribute Details

#remote_object_idObject (readonly)

Returns the value of attribute remote_object_id.



9
10
11
# File 'lib/capybara/lightpanda/node.rb', line 9

def remote_object_id
  @remote_object_id
end

Instance Method Details

#==(other) ⇒ Object Also known as: eql?

Equality compares the underlying DOM node via backendNodeId, the only identity that’s stable across CDP calls. NO fast path on remote_object_id: two wrappers with the same remote_object_id can resolve to different backendNodeIds (one cached at 42, the other still nil from a transient describeNode failure), and a remote-id fast path there would return ‘true` while `#hash` returned different values, violating the hash contract. When either side fails to resolve, the nodes are treated as not equal so stale wrappers don’t collapse onto each other.



212
213
214
215
216
217
218
# File 'lib/capybara/lightpanda/node.rb', line 212

def ==(other)
  return false unless other.is_a?(self.class)

  left = backend_node_id
  right = other.backend_node_id
  !left.nil? && left == right
end

#[](name) ⇒ Object

Smart property/attribute getter (Cuprite pattern). Returns resolved URLs for src/href, raw attributes otherwise.



90
91
92
# File 'lib/capybara/lightpanda/node.rb', line 90

def [](name)
  call(PROPERTY_OR_ATTRIBUTE_JS, name.to_s)
end

#all_textObject



21
22
23
24
# File 'lib/capybara/lightpanda/node.rb', line 21

def all_text
  ensure_connected
  filter_text(call("function() { return this.textContent }"))
end

#backend_node_idObject



230
231
232
# File 'lib/capybara/lightpanda/node.rb', line 230

def backend_node_id
  @backend_node_id ||= driver.browser.backend_node_id(@remote_object_id)
end

#checked?Boolean

Returns:

  • (Boolean)


170
171
172
# File 'lib/capybara/lightpanda/node.rb', line 170

def checked?
  call("function() { return this.checked }")
end

#click(_keys = [], **_options) ⇒ Object



102
103
104
105
# File 'lib/capybara/lightpanda/node.rb', line 102

def click(_keys = [], **_options)
  call(CLICK_JS)
  driver.browser.wait_for_idle
end

#disabled?Boolean

Returns:

  • (Boolean)


178
179
180
# File 'lib/capybara/lightpanda/node.rb', line 178

def disabled?
  call(DISABLED_JS)
end

#double_click(_keys = [], **_options) ⇒ Object



111
112
113
# File 'lib/capybara/lightpanda/node.rb', line 111

def double_click(_keys = [], **_options)
  call("function() { this.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true})) }")
end

#find_css(selector) ⇒ Object



199
200
201
202
# File 'lib/capybara/lightpanda/node.rb', line 199

def find_css(selector)
  object_ids = driver.browser.find_within(@remote_object_id, "css", selector)
  object_ids.map { |oid| self.class.new(driver, oid) }
end

#find_xpath(selector) ⇒ Object



194
195
196
197
# File 'lib/capybara/lightpanda/node.rb', line 194

def find_xpath(selector)
  object_ids = driver.browser.find_within(@remote_object_id, "xpath", selector)
  object_ids.map { |oid| self.class.new(driver, oid) }
end

#hashObject

Hash on backendNodeId so equal nodes always hash the same. When describeNode fails (returns nil) the bucket collapses to ‘nil.hash`; combined with `==` returning false for nil-resolved nodes, Set/Hash membership stays consistent (collisions are allowed for unequal objects).



226
227
228
# File 'lib/capybara/lightpanda/node.rb', line 226

def hash
  backend_node_id.hash
end

#hoverObject



115
116
117
# File 'lib/capybara/lightpanda/node.rb', line 115

def hover
  call("function() { this.dispatchEvent(new MouseEvent('mouseover', {bubbles: true, cancelable: true})) }")
end

#moving?(delay: MOVING_WAIT_DELAY) ⇒ Boolean

Returns true when the element’s bounding rect has changed between two samples taken ‘delay` seconds apart. Lightpanda has no real animation frame loop so most “movement” is JS-driven (style mutations); this works because getBoundingClientRect reflects those mutations.

Returns:

  • (Boolean)


52
53
54
55
56
# File 'lib/capybara/lightpanda/node.rb', line 52

def moving?(delay: MOVING_WAIT_DELAY)
  previous = rect
  sleep(delay)
  previous != rect
end

#multiple?Boolean

Returns:

  • (Boolean)


186
187
188
# File 'lib/capybara/lightpanda/node.rb', line 186

def multiple?
  call("function() { return this.multiple }")
end

#obscured?Boolean

Returns:

  • (Boolean)


44
45
46
# File 'lib/capybara/lightpanda/node.rb', line 44

def obscured?
  call(OBSCURED_JS)
end

#pathObject



190
191
192
# File 'lib/capybara/lightpanda/node.rb', line 190

def path
  call(GET_PATH_JS)
end

#readonly?Boolean

Returns:

  • (Boolean)


182
183
184
# File 'lib/capybara/lightpanda/node.rb', line 182

def readonly?
  call("function() { return this.readOnly }")
end

#rectObject



40
41
42
# File 'lib/capybara/lightpanda/node.rb', line 40

def rect
  call(GET_RECT_JS)
end

#right_click(_keys = [], **_options) ⇒ Object



107
108
109
# File 'lib/capybara/lightpanda/node.rb', line 107

def right_click(_keys = [], **_options)
  call("function() { this.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true})) }")
end

#select_optionObject



134
135
136
# File 'lib/capybara/lightpanda/node.rb', line 134

def select_option
  call(SELECT_OPTION_JS)
end

#selected?Boolean

Returns:

  • (Boolean)


174
175
176
# File 'lib/capybara/lightpanda/node.rb', line 174

def selected?
  call("function() { return !!this.selected }")
end

#send_keysObject



150
151
152
153
# File 'lib/capybara/lightpanda/node.rb', line 150

def send_keys(*)
  call("function() { this.focus() }")
  driver.browser.keyboard.type(*)
end

#set(value, **_options) ⇒ Object



119
120
121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/capybara/lightpanda/node.rb', line 119

def set(value, **_options)
  case tag_name
  when "input"
    fill_input(value)
  when "textarea"
    call(SET_VALUE_JS, truncate_to_maxlength(value.to_s))
  else
    # `contenteditable` cascades through descendants. Check
    # `isContentEditable`, then fall back to walking ancestors for
    # `contenteditable` since Lightpanda doesn't expose the property on
    # every element. EDITABLE_HOST_JS encapsulates that check.
    call("function(v) { this.innerHTML = v }", value.to_s) if call(EDITABLE_HOST_JS)
  end
end

#shadow_rootObject



75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/capybara/lightpanda/node.rb', line 75

def shadow_root
  result = driver.browser.with_default_context_wait do
    driver.browser.call_function_on(
      @remote_object_id,
      "function() { return this.shadowRoot }",
      return_by_value: false
    )
  end
  return nil unless result.is_a?(Hash) && result["objectId"]

  self.class.new(driver, result["objectId"])
end

#style(styles) ⇒ Object



98
99
100
# File 'lib/capybara/lightpanda/node.rb', line 98

def style(styles)
  styles.to_h { |style| [style, call(GET_STYLE_JS, style)] }
end

#tag_nameObject



155
156
157
158
159
160
161
162
163
164
# File 'lib/capybara/lightpanda/node.rb', line 155

def tag_name
  # ShadowRoot/DocumentFragment have no tagName; report a stable label so
  # Capybara's failure messages can render `tag="ShadowRoot"`.
  # Memoized: an objectId points to a single DOM node whose tagName is
  # immutable for that node's lifetime.
  @tag_name ||= call("function() {
    if (this.nodeType === 11) return 'ShadowRoot';
    return this.tagName ? this.tagName.toLowerCase() : '';
  }")
end

#textObject



16
17
18
19
# File 'lib/capybara/lightpanda/node.rb', line 16

def text
  ensure_connected
  call("function() { return this.textContent }")
end

#unselect_optionObject



138
139
140
141
142
143
144
145
146
147
148
# File 'lib/capybara/lightpanda/node.rb', line 138

def unselect_option
  unless call("function() {
    var s = this.parentElement;
    while (s && (s.tagName || '').toUpperCase() !== 'SELECT') s = s.parentElement;
    return !!(s && s.multiple);
  }")
    raise Capybara::UnselectNotAllowed, "Cannot unselect option from single select box."
  end

  call(UNSELECT_OPTION_JS)
end

#valueObject



94
95
96
# File 'lib/capybara/lightpanda/node.rb', line 94

def value
  call(GET_VALUE_JS)
end

#visible?Boolean

Returns:

  • (Boolean)


166
167
168
# File 'lib/capybara/lightpanda/node.rb', line 166

def visible?
  call(VISIBLE_JS)
end

#visible_textObject

Lightpanda’s innerText returns textContent verbatim (no rendering, so no hidden-descendant filtering). Walk descendants ourselves, skipping nodes that fail VISIBLE_JS, and emit newlines around block-display elements (the part of innerText behavior we still need).



30
31
32
33
34
35
36
37
38
# File 'lib/capybara/lightpanda/node.rb', line 30

def visible_text
  ensure_connected
  call(VISIBLE_TEXT_JS).to_s
                       .gsub(/\A[[:space:]&&[^\u00A0]]+/, "")
                       .gsub(/[[:space:]&&[^\u00A0]]+\z/, "")
                       .gsub(/[ \t\f\v]+/, " ")
                       .gsub(/[ \t\f\v]*\n[ \t\f\v\n]*/, "\n")
                       .tr("\u00A0", " ")
end

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

Block until the element’s rect stabilises across two consecutive samples or ‘attempts` polls have elapsed (whichever first). Returns the last rect read; never raises. Mirrors ferrum’s wait_for_stop_moving but no NodeMovingError because Lightpanda has no rendering loop, so a caller silently proceeding with the last rect is the right default.



63
64
65
66
67
68
69
70
71
72
73
# File 'lib/capybara/lightpanda/node.rb', line 63

def wait_for_stop_moving(delay: MOVING_WAIT_DELAY, attempts: MOVING_WAIT_ATTEMPTS)
  previous = rect
  attempts.times do
    sleep(delay)
    current = rect
    return current if current == previous

    previous = current
  end
  previous
end