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.



293
294
295
296
297
298
299
# File 'lib/capybara/lightpanda/node.rb', line 293

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.



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

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

#all_textObject



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

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

#backend_node_idObject



311
312
313
314
315
# File 'lib/capybara/lightpanda/node.rb', line 311

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

#checked?Boolean

Returns:

  • (Boolean)


244
245
246
# File 'lib/capybara/lightpanda/node.rb', line 244

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

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



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

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

#disabled?Boolean

Returns:

  • (Boolean)


252
253
254
# File 'lib/capybara/lightpanda/node.rb', line 252

def disabled?
  call(DISABLED_JS)
end

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



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

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

Files reach the page the way Cuprite's #316 does it: a hidden <input type=file> is attached to this element's document, DOM.setFileInputFiles points it at the paths (the browser reads the bytes off disk itself), and the drop JS moves input.files into the DataTransfer and removes the input. Previously the bytes were base64'd into the Runtime.callFunctionOn message, which capped a drop at ~70 MB under --cdp-max-message-size and pinned every byte in Ruby; now the size ceiling is Lightpanda's own file handling. Paths are read on the machine running Lightpanda (local for the spawned process), exactly like attach_file.

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



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

def drop(*args)
  paths, strings = partition_drop_args(args)
  input = paths.empty? ? nil : attach_drop_input(paths)
  call(DROP_JS, input, strings.to_json)
  nil
end

#exists?Boolean

Quiet form of the isConnected guard every other operation carries: true while the node is still attached to a live document, false once it has been detached or its document navigated away (mirrors Ferrum's Node#exists?, whose probe is DOM.resolveNode). Anything else that goes wrong still raises — only "gone" is turned into false.

Returns:

  • (Boolean)


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

def exists?
  call("function() { return true; }")
rescue ObsoleteNode, NodeNotFoundError, NoExecutionContextError
  false
end

#find_css(selector) ⇒ Object



280
281
282
283
# File 'lib/capybara/lightpanda/node.rb', line 280

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



275
276
277
278
# File 'lib/capybara/lightpanda/node.rb', line 275

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



307
308
309
# File 'lib/capybara/lightpanda/node.rb', line 307

def hash
  backend_node_id.hash
end

#hoverObject

A real pointer entering an element fires mouseover (bubbling) AND mouseenter (non-bubbling), in that order. Dispatching only mouseover silently no-ops the mouseenter->menu#open Stimulus idiom and the Floating UI / tippy-style menus built on it — the dominant hover-menu pattern in Rails apps — so fire both. CSS :hover still reveals nothing (upstream tracks no pointer state); test/features/hover_test.rb pins both halves.



145
146
147
# File 'lib/capybara/lightpanda/node.rb', line 145

def hover
  call(HOVER_JS)
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)


66
67
68
69
70
# File 'lib/capybara/lightpanda/node.rb', line 66

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

#multiple?Boolean

Returns:

  • (Boolean)


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

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

#nativeObject

Capybara::Driver::Node#native returns the constructor's second argument, which here is the raw CDP objectId — a String. So the Selenium/Cuprite idiom element.native.send_keys(...) (solidus's return_authorizations_spec.rb does exactly that) died with "undefined method 'send_keys' for an instance of String". This driver has no lower-level node object behind the Capybara one — the CDP handle IS this Node (see #remote_object_id) — so native is self.

Safe against Capybara::Driver::Node#==, which compares native == other.native and would recurse forever on a self-returning native: #== and #eql? below are full overrides that never call super and never read #native.



28
29
30
# File 'lib/capybara/lightpanda/node.rb', line 28

def native
  self
end

#obscured?Boolean

Returns:

  • (Boolean)


58
59
60
# File 'lib/capybara/lightpanda/node.rb', line 58

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.



270
271
272
273
# File 'lib/capybara/lightpanda/node.rb', line 270

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

#pathObject



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

def path
  call(GET_PATH_JS)
end

#readonly?Boolean

Returns:

  • (Boolean)


256
257
258
# File 'lib/capybara/lightpanda/node.rb', line 256

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

#rectObject



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

def rect
  call(GET_RECT_JS)
end

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



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

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

#scroll_byObject



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

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



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

def scroll_to(*); end

#select_optionObject



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

def select_option
  call(SELECT_OPTION_JS)
end

#selected?Boolean

Returns:

  • (Boolean)


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

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

#send_keysObject



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

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

#set(value, **_options) ⇒ Object



172
173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/capybara/lightpanda/node.rb', line 172

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.



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

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



121
122
123
# File 'lib/capybara/lightpanda/node.rb', line 121

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

#tag_nameObject



229
230
231
232
233
234
235
236
237
238
# File 'lib/capybara/lightpanda/node.rb', line 229

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



32
33
34
# File 'lib/capybara/lightpanda/node.rb', line 32

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



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

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

#unselect_optionObject

Raises:

  • (Capybara::UnselectNotAllowed)


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

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

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

#valueObject



117
118
119
# File 'lib/capybara/lightpanda/node.rb', line 117

def value
  call(GET_VALUE_JS)
end

#visible?Boolean

Returns:

  • (Boolean)


240
241
242
# File 'lib/capybara/lightpanda/node.rb', line 240

def visible?
  call(VISIBLE_JS)
end

#visible_textObject

Delegates to _lightpanda.visibleText, which gates on visibility (a not-visible element reads as "" — WebDriver semantics) and otherwise hands the rendered-text collection (block line breaks + display:none descendant skipping) to native innerText (#2785/#2795). We normalize the whitespace here to match Capybara's expected Chrome semantics.



45
46
47
48
49
50
51
52
# File 'lib/capybara/lightpanda/node.rb', line 45

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.



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

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