Class: Capybara::Lightpanda::Node
- Inherits:
-
Driver::Node
- Object
- Driver::Node
- Capybara::Lightpanda::Node
- 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
-
#remote_object_id ⇒ Object
readonly
Returns the value of attribute remote_object_id.
Instance Method Summary collapse
-
#==(other) ⇒ Object
(also: #eql?)
Equality compares the underlying DOM node via backendNodeId, the only identity that's stable across CDP calls.
-
#[](name) ⇒ Object
Smart property/attribute getter (Cuprite pattern).
- #all_text ⇒ Object
- #backend_node_id ⇒ Object
- #checked? ⇒ Boolean
- #click(_keys = [], **_options) ⇒ Object
- #disabled? ⇒ Boolean
- #double_click(_keys = [], **_options) ⇒ Object
-
#drop(*args) ⇒ Object
Capybara's drag-and-drop API (
Element#drop). -
#exists? ⇒ Boolean
Quiet form of the
isConnectedguard 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'sNode#exists?, whose probe isDOM.resolveNode). - #find_css(selector) ⇒ Object
- #find_xpath(selector) ⇒ Object
-
#hash ⇒ Object
Hash on backendNodeId so equal nodes always hash the same.
-
#hover ⇒ Object
A real pointer entering an element fires
mouseover(bubbling) ANDmouseenter(non-bubbling), in that order. -
#initialize(driver, remote_object_id) ⇒ Node
constructor
A new instance of Node.
-
#moving?(delay: MOVING_WAIT_DELAY) ⇒ Boolean
Returns true when the element's bounding rect has changed between two samples taken
delayseconds apart. - #multiple? ⇒ Boolean
-
#native ⇒ Object
Capybara::Driver::Node#native returns the constructor's second argument, which here is the raw CDP objectId — a String.
- #obscured? ⇒ Boolean
-
#parents ⇒ Object
Ancestor chain from
parentNodeup to (but not including)document, returned as Lightpanda::Node wrappers. - #path ⇒ Object
- #readonly? ⇒ Boolean
- #rect ⇒ Object
- #right_click(_keys = [], **_options) ⇒ Object
- #scroll_by ⇒ Object
-
#scroll_to ⇒ Object
Kept as a deliberate no-op despite upstream now tracking scroll position (
window.scrollTo/scrollByupdatewindow._scroll_pos,ElementexposesscrollTop/scrollLeft— Window.zig/Element.zig). - #select_option ⇒ Object
- #selected? ⇒ Boolean
- #send_keys ⇒ Object
- #set(value, **_options) ⇒ Object
-
#shadow_root ⇒ Object
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.
- #style(styles) ⇒ Object
- #tag_name ⇒ Object
- #text ⇒ Object
-
#trigger(event) ⇒ Object
Dispatch an arbitrary DOM event by name.
- #unselect_option ⇒ Object
- #value ⇒ Object
- #visible? ⇒ Boolean
-
#visible_text ⇒ Object
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).
-
#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
attemptspolls have elapsed (whichever first).
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_id ⇒ Object (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_text ⇒ Object
36 37 38 |
# File 'lib/capybara/lightpanda/node.rb', line 36 def all_text filter_text(call("function() { return this.textContent }")) end |
#backend_node_id ⇒ Object
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
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 = [], **) call(CLICK_JS) driver.browser.wait_for_idle end |
#disabled? ⇒ 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 = [], **) 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.
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 |
#hash ⇒ Object
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 |
#hover ⇒ Object
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.
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
260 261 262 |
# File 'lib/capybara/lightpanda/node.rb', line 260 def multiple? call("function() { return this.multiple }") end |
#native ⇒ Object
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
58 59 60 |
# File 'lib/capybara/lightpanda/node.rb', line 58 def obscured? call(OBSCURED_JS) end |
#parents ⇒ Object
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 |
#path ⇒ Object
264 265 266 |
# File 'lib/capybara/lightpanda/node.rb', line 264 def path call(GET_PATH_JS) end |
#readonly? ⇒ Boolean
256 257 258 |
# File 'lib/capybara/lightpanda/node.rb', line 256 def readonly? call("function() { return this.readOnly }") end |
#rect ⇒ Object
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 = [], **) call("function() { this.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true})) }") end |
#scroll_by ⇒ Object
162 |
# File 'lib/capybara/lightpanda/node.rb', line 162 def scroll_by(*); end |
#scroll_to ⇒ Object
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_option ⇒ Object
214 215 216 |
# File 'lib/capybara/lightpanda/node.rb', line 214 def select_option call(SELECT_OPTION_JS) end |
#selected? ⇒ Boolean
248 249 250 |
# File 'lib/capybara/lightpanda/node.rb', line 248 def selected? call("function() { return !!this.selected }") end |
#send_keys ⇒ Object
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, **) 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_root ⇒ Object
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_name ⇒ Object
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 |
#text ⇒ Object
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_option ⇒ Object
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 |
#value ⇒ Object
117 118 119 |
# File 'lib/capybara/lightpanda/node.rb', line 117 def value call(GET_VALUE_JS) end |
#visible? ⇒ Boolean
240 241 242 |
# File 'lib/capybara/lightpanda/node.rb', line 240 def visible? call(VISIBLE_JS) end |
#visible_text ⇒ Object
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 |