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.



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

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



272
273
274
275
276
# File 'lib/capybara/lightpanda/node.rb', line 272

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

#checked?Boolean

Returns:

  • (Boolean)


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

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)


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

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

#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”.



163
164
165
166
167
# File 'lib/capybara/lightpanda/node.rb', line 163

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

#find_css(selector) ⇒ Object



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

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



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

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



268
269
270
# File 'lib/capybara/lightpanda/node.rb', line 268

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)


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

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



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

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

#pathObject



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

def path
  call(GET_PATH_JS)
end

#readonly?Boolean

Returns:

  • (Boolean)


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

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



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

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



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

def scroll_to(*); end

#select_optionObject



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

def select_option
  call(SELECT_OPTION_JS)
end

#selected?Boolean

Returns:

  • (Boolean)


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

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

#send_keysObject



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

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

#set(value, **_options) ⇒ Object



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

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



190
191
192
193
194
195
196
197
198
199
# File 'lib/capybara/lightpanda/node.rb', line 190

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



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

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

#unselect_optionObject



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

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)


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

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