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
- 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
-
#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
-
#drag_to(other, html5: nil, delay: 0.05, drop_modifiers: []) ⇒ Object
Capybara's
Element#drag_to— HTML5 half only. -
#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.
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_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
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
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 = [], **) call(CLICK_JS) driver.browser.wait_for_idle end |
#disabled? ⇒ 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 = [], **) 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.
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 |
#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).
342 343 344 |
# File 'lib/capybara/lightpanda/node.rb', line 342 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
295 296 297 |
# File 'lib/capybara/lightpanda/node.rb', line 295 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.
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 |
#path ⇒ Object
299 300 301 |
# File 'lib/capybara/lightpanda/node.rb', line 299 def path call(GET_PATH_JS) end |
#readonly? ⇒ Boolean
291 292 293 |
# File 'lib/capybara/lightpanda/node.rb', line 291 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
249 250 251 |
# File 'lib/capybara/lightpanda/node.rb', line 249 def select_option call(SELECT_OPTION_JS) end |
#selected? ⇒ Boolean
283 284 285 |
# File 'lib/capybara/lightpanda/node.rb', line 283 def selected? call("function() { return !!this.selected }") end |
#send_keys ⇒ Object
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, **) 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
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 |
#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
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 |
#value ⇒ Object
117 118 119 |
# File 'lib/capybara/lightpanda/node.rb', line 117 def value call(GET_VALUE_JS) end |
#visible? ⇒ Boolean
275 276 277 |
# File 'lib/capybara/lightpanda/node.rb', line 275 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 |