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.



246
247
248
249
250
251
252
# File 'lib/capybara/lightpanda/node.rb', line 246

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.



85
86
87
# File 'lib/capybara/lightpanda/node.rb', line 85

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



264
265
266
267
268
# File 'lib/capybara/lightpanda/node.rb', line 264

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

#checked?Boolean

Returns:

  • (Boolean)


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

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

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



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

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

#disabled?Boolean

Returns:

  • (Boolean)


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

def disabled?
  call(DISABLED_JS)
end

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



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

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

#drop(*args) ⇒ Object

Capybara’s drag-and-drop API (‘Element#drop`). String/Pathname arguments are file paths — read here and rebuilt as `File` objects in the page; Hash arguments are `{ mime_type => data }` string drops. We assemble a `DataTransfer` and fire `dragenter` -> `dragover` -> `drop` on this element, so HTML5 dropzones see the payload via `event.dataTransfer`.

DataTransfer/DataTransferItem/DragEvent landed upstream in PR #2671 (build ≥6699) and are guaranteed by the MINIMUM_NIGHTLY_BUILD floor; without them the drop JS raises “DataTransfer is not defined”.



161
162
163
164
165
# File 'lib/capybara/lightpanda/node.rb', line 161

def drop(*args)
  files, strings = partition_drop_args(args)
  call(DROP_JS, files.to_json, strings.to_json)
  nil
end

#find_css(selector) ⇒ Object



233
234
235
236
# File 'lib/capybara/lightpanda/node.rb', line 233

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



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

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



260
261
262
# File 'lib/capybara/lightpanda/node.rb', line 260

def hash
  backend_node_id.hash
end

#hoverObject



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

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)


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

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



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

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

#pathObject



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

def path
  call(GET_PATH_JS)
end

#readonly?Boolean

Returns:

  • (Boolean)


209
210
211
# File 'lib/capybara/lightpanda/node.rb', line 209

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



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

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

#scroll_byObject



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

def scroll_by(*); end

#scroll_toObject

Kept as a deliberate no-op despite upstream now tracking scroll position (‘window.scrollTo`/`scrollBy` update `window._scroll_pos`, `Element` exposes `scrollTop`/`scrollLeft` — Window.zig/Element.zig). Wiring it would still misbehave: Lightpanda never clamps to content height (`scrollHeight`/`clientHeight` are a hardcoded 1e8), so `:bottom`/`:center` are meaningless; element scroll is decoupled from window scroll; and with no layout `getBoundingClientRect` isn’t scroll-aware, so ‘scroll_to(el, align:)` can’t position anything. So there’s nothing meaningful to scroll to. Silently succeed so callers like ‘session.scroll_to(find(’#thing’))‘ don’t crash with NotImplementedError. The ‘:scroll` capability stays in `capybara_skip`. (Window-position scroll IS reachable for real via `execute_script(’window.scrollTo(…)‘)` if a caller truly needs it.)



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

def scroll_to(*); end

#select_optionObject



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

def select_option
  call(SELECT_OPTION_JS)
end

#selected?Boolean

Returns:

  • (Boolean)


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

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

#send_keysObject



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

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

#set(value, **_options) ⇒ Object



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

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

Routed through #call (not a bare call_function_on) so a detached host raises ObsoleteNode like every other node operation — Capybara’s automatic_reload then re-finds the host instead of silently reading a stale shadowRoot.



76
77
78
79
80
81
# File 'lib/capybara/lightpanda/node.rb', line 76

def shadow_root
  result = call(SHADOW_ROOT_JS, return_by_value: false)
  return nil unless result.is_a?(Hash) && result["objectId"]

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

#style(styles) ⇒ Object



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

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

#tag_nameObject



182
183
184
185
186
187
188
189
190
191
# File 'lib/capybara/lightpanda/node.rb', line 182

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



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

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

#unselect_optionObject

Raises:

  • (Capybara::UnselectNotAllowed)


171
172
173
174
175
# File 'lib/capybara/lightpanda/node.rb', line 171

def unselect_option
  return unless call(UNSELECT_OPTION_JS) == "not_multiple"

  raise Capybara::UnselectNotAllowed, "Cannot unselect option from single select box."
end

#valueObject



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

def value
  call(GET_VALUE_JS)
end

#visible?Boolean

Returns:

  • (Boolean)


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

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