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
DRAG_MODIFIER_ALIASES =

Maps Capybara's documented drop_modifiers aliases onto the DragEvent init keys (ctrlKey, metaKey, ...). Same table as Cuprite's #315.

{ control: :ctrl, command: :meta, cmd: :meta }.freeze

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.



328
329
330
331
332
333
334
# File 'lib/capybara/lightpanda/node.rb', line 328

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



346
347
348
349
350
# File 'lib/capybara/lightpanda/node.rb', line 346

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

#checked?Boolean

Returns:

  • (Boolean)


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

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)


287
288
289
# File 'lib/capybara/lightpanda/node.rb', line 287

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

#drag_to(other, html5: nil, delay: 0.05, drop_modifiers: []) ⇒ Object

Capybara's Element#drag_to — HTML5 half only. HTML5_DRAG_JS replays Capybara's own Selenium HTML5_DRAG_DROP_SCRIPT (the same source Cuprite's drag.js ports): dragstart on the draggable ancestor, then dragenter -> 2x dragover -> dragleave/drop -> dragend, setTimeout-paced, sharing one DataTransfer so setData in the page's dragstart handler is readable at drop. Runs through evaluate_async (the script signals completion via the appended callback), so the drag has fully played out before this method returns.

The legacy path is coordinate-based mouse dragging, which Lightpanda cannot express (no layout to produce coordinates from) — it raises instead of silently no-oping. html5: nil auto-detects like Selenium does, via LEGACY_DRAG_CHECK_JS: we dispatch a synthetic mousedown where Selenium presses a real button, then apply the same prevented-or-no-draggable-ancestor test.

steps:/scroll: (Cuprite's legacy-path knobs) are accepted and ignored so suites migrating from cuprite don't ArgumentError.



236
237
238
239
240
241
242
243
244
245
246
247
# File 'lib/capybara/lightpanda/node.rb', line 236

def drag_to(other, html5: nil, delay: 0.05, drop_modifiers: [], **)
  keys = Array(drop_modifiers).map { |m| DRAG_MODIFIER_ALIASES.fetch(m.to_sym, m.to_sym).to_s }
  html5 = !call(LEGACY_DRAG_CHECK_JS) if html5.nil?
  unless html5
    raise NotImplementedError,
          "drag_to needs coordinate mouse dispatch for non-HTML5 (legacy) drags, which Lightpanda " \
          "cannot do (no layout). Pass `html5: true` to force HTML5 DragEvent simulation."
  end

  driver.browser.evaluate_async(HTML5_DRAG_JS, self, other, (delay * 1000).to_i, keys)
  nil
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



315
316
317
318
# File 'lib/capybara/lightpanda/node.rb', line 315

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



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

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



342
343
344
# File 'lib/capybara/lightpanda/node.rb', line 342

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)


295
296
297
# File 'lib/capybara/lightpanda/node.rb', line 295

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.



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

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

#pathObject



299
300
301
# File 'lib/capybara/lightpanda/node.rb', line 299

def path
  call(GET_PATH_JS)
end

#readonly?Boolean

Returns:

  • (Boolean)


291
292
293
# File 'lib/capybara/lightpanda/node.rb', line 291

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



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

def select_option
  call(SELECT_OPTION_JS)
end

#selected?Boolean

Returns:

  • (Boolean)


283
284
285
# File 'lib/capybara/lightpanda/node.rb', line 283

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

#send_keysObject



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

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



264
265
266
267
268
269
270
271
272
273
# File 'lib/capybara/lightpanda/node.rb', line 264

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)


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

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)


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

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