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.



235
236
237
238
239
240
241
# File 'lib/capybara/lightpanda/node.rb', line 235

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.



87
88
89
# File 'lib/capybara/lightpanda/node.rb', line 87

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

#all_textObject



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

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

#backend_node_idObject



253
254
255
256
257
# File 'lib/capybara/lightpanda/node.rb', line 253

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

#checked?Boolean

Returns:

  • (Boolean)


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

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

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



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

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

#disabled?Boolean

Returns:

  • (Boolean)


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

def disabled?
  call(DISABLED_JS)
end

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



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

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

#find_css(selector) ⇒ Object



222
223
224
225
# File 'lib/capybara/lightpanda/node.rb', line 222

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



217
218
219
220
# File 'lib/capybara/lightpanda/node.rb', line 217

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).



249
250
251
# File 'lib/capybara/lightpanda/node.rb', line 249

def hash
  backend_node_id.hash
end

#hoverObject



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

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)


49
50
51
52
53
# File 'lib/capybara/lightpanda/node.rb', line 49

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

#multiple?Boolean

Returns:

  • (Boolean)


202
203
204
# File 'lib/capybara/lightpanda/node.rb', line 202

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

#obscured?Boolean

Returns:

  • (Boolean)


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

def obscured?
  call(OBSCURED_JS)
end

#parentsObject

Ancestor chain from ‘parentNode` up to (but not including) `document`, returned as Lightpanda::Node wrappers. Mirrors Cuprite’s ‘Node#parents`.



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

def parents
  oids = driver.browser.parents_of(@remote_object_id)
  oids.map { |oid| self.class.new(driver, oid) }
end

#pathObject



206
207
208
# File 'lib/capybara/lightpanda/node.rb', line 206

def path
  call(GET_PATH_JS)
end

#readonly?Boolean

Returns:

  • (Boolean)


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

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

#rectObject



37
38
39
# File 'lib/capybara/lightpanda/node.rb', line 37

def rect
  call(GET_RECT_JS)
end

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



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

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

#scroll_byObject



125
# File 'lib/capybara/lightpanda/node.rb', line 125

def scroll_by(*); end

#scroll_toObject

Lightpanda has no rendering engine — ‘window.scrollTo` / `scrollIntoView` are no-ops at the browser level, and `getBoundingClientRect` reflects logical-DOM geometry rather than scroll-aware viewport coords. So there’s nothing to scroll. Silently succeed so callers like ‘session.scroll_to(find(’#thing’))‘ (Selenium-flavoured specs leaning on real layout) don’t crash with NotImplementedError; assertions that depend on post-scroll visibility are already gated by the cuprite fallback in dual-driver setups.



124
# File 'lib/capybara/lightpanda/node.rb', line 124

def scroll_to(*); end

#select_optionObject



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

def select_option
  call(SELECT_OPTION_JS)
end

#selected?Boolean

Returns:

  • (Boolean)


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

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

#send_keysObject



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

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

#set(value, **_options) ⇒ Object



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

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



72
73
74
75
76
77
78
79
80
81
82
83
# File 'lib/capybara/lightpanda/node.rb', line 72

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



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

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

#tag_nameObject



171
172
173
174
175
176
177
178
179
180
# File 'lib/capybara/lightpanda/node.rb', line 171

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
# File 'lib/capybara/lightpanda/node.rb', line 16

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

#trigger(event) ⇒ Object

Dispatch an arbitrary DOM event by name. Mirrors Cuprite’s Node#trigger — picks the right Event constructor for known mouse/focus/form names and falls back to a generic Event for everything else (so callers can fire custom events like ‘node.trigger(’lp:custom’)‘).



131
132
133
# File 'lib/capybara/lightpanda/node.rb', line 131

def trigger(event)
  call(TRIGGER_JS, event.to_s)
end

#unselect_optionObject



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

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



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

def value
  call(GET_VALUE_JS)
end

#visible?Boolean

Returns:

  • (Boolean)


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

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).



28
29
30
31
32
33
34
35
# File 'lib/capybara/lightpanda/node.rb', line 28

def visible_text
  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.



60
61
62
63
64
65
66
67
68
69
70
# File 'lib/capybara/lightpanda/node.rb', line 60

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