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.



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

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.



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

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

#all_textObject



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

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

#backend_node_idObject



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

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

#checked?Boolean

Returns:

  • (Boolean)


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

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

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



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

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

#disabled?Boolean

Returns:

  • (Boolean)


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

def disabled?
  call(DISABLED_JS)
end

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



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

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

#find_css(selector) ⇒ Object



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

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



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

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



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

def hash
  backend_node_id.hash
end

#hoverObject



115
116
117
# File 'lib/capybara/lightpanda/node.rb', line 115

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)


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

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

#multiple?Boolean

Returns:

  • (Boolean)


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

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

#obscured?Boolean

Returns:

  • (Boolean)


44
45
46
# File 'lib/capybara/lightpanda/node.rb', line 44

def obscured?
  call(OBSCURED_JS)
end

#pathObject



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

def path
  call(GET_PATH_JS)
end

#readonly?Boolean

Returns:

  • (Boolean)


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

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

#rectObject



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

def rect
  call(GET_RECT_JS)
end

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



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

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

#select_optionObject



142
143
144
# File 'lib/capybara/lightpanda/node.rb', line 142

def select_option
  call(SELECT_OPTION_JS)
end

#selected?Boolean

Returns:

  • (Boolean)


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

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

#send_keysObject



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

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

#set(value, **_options) ⇒ Object



127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/capybara/lightpanda/node.rb', line 127

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



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

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



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

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

#tag_nameObject



163
164
165
166
167
168
169
170
171
172
# File 'lib/capybara/lightpanda/node.rb', line 163

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

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



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

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

#unselect_optionObject



146
147
148
149
150
151
152
153
154
155
156
# File 'lib/capybara/lightpanda/node.rb', line 146

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



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

def value
  call(GET_VALUE_JS)
end

#visible?Boolean

Returns:

  • (Boolean)


174
175
176
# File 'lib/capybara/lightpanda/node.rb', line 174

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



30
31
32
33
34
35
36
37
38
# File 'lib/capybara/lightpanda/node.rb', line 30

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



63
64
65
66
67
68
69
70
71
72
73
# File 'lib/capybara/lightpanda/node.rb', line 63

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