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`).
- #find_css(selector) ⇒ Object
- #find_xpath(selector) ⇒ Object
-
#hash ⇒ Object
Hash on backendNodeId so equal nodes always hash the same.
- #hover ⇒ Object
-
#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 ‘delay` seconds apart.
- #multiple? ⇒ Boolean
- #obscured? ⇒ Boolean
-
#parents ⇒ Object
Ancestor chain from ‘parentNode` up 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`/`scrollBy` update `window._scroll_pos`, `Element` exposes `scrollTop`/`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
Lightpanda’s innerText returns textContent verbatim (no rendering, so no hidden-descendant filtering).
-
#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).
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.
246 247 248 249 250 251 252 |
# File 'lib/capybara/lightpanda/node.rb', line 246 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.
85 86 87 |
# File 'lib/capybara/lightpanda/node.rb', line 85 def [](name) call(PROPERTY_OR_ATTRIBUTE_JS, name.to_s) end |
#all_text ⇒ Object
20 21 22 |
# File 'lib/capybara/lightpanda/node.rb', line 20 def all_text filter_text(call("function() { return this.textContent }")) end |
#backend_node_id ⇒ Object
264 265 266 267 268 |
# File 'lib/capybara/lightpanda/node.rb', line 264 def backend_node_id @backend_node_id ||= driver.browser.backend_node_id(@remote_object_id) rescue BrowserError nil end |
#checked? ⇒ Boolean
197 198 199 |
# File 'lib/capybara/lightpanda/node.rb', line 197 def checked? call("function() { return this.checked }") end |
#click(_keys = [], **_options) ⇒ Object
97 98 99 100 |
# File 'lib/capybara/lightpanda/node.rb', line 97 def click(_keys = [], **) call(CLICK_JS) driver.browser.wait_for_idle end |
#disabled? ⇒ Boolean
205 206 207 |
# File 'lib/capybara/lightpanda/node.rb', line 205 def disabled? call(DISABLED_JS) end |
#double_click(_keys = [], **_options) ⇒ Object
106 107 108 |
# File 'lib/capybara/lightpanda/node.rb', line 106 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 — 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”.
161 162 163 164 165 |
# File 'lib/capybara/lightpanda/node.rb', line 161 def drop(*args) files, strings = partition_drop_args(args) call(DROP_JS, files.to_json, strings.to_json) nil end |
#find_css(selector) ⇒ Object
233 234 235 236 |
# File 'lib/capybara/lightpanda/node.rb', line 233 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
228 229 230 231 |
# File 'lib/capybara/lightpanda/node.rb', line 228 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).
260 261 262 |
# File 'lib/capybara/lightpanda/node.rb', line 260 def hash backend_node_id.hash end |
#hover ⇒ Object
110 111 112 |
# File 'lib/capybara/lightpanda/node.rb', line 110 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.
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
213 214 215 |
# File 'lib/capybara/lightpanda/node.rb', line 213 def multiple? call("function() { return this.multiple }") end |
#obscured? ⇒ Boolean
41 42 43 |
# File 'lib/capybara/lightpanda/node.rb', line 41 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`.
223 224 225 226 |
# File 'lib/capybara/lightpanda/node.rb', line 223 def parents oids = driver.browser.parents_of(@remote_object_id) oids.map { |oid| self.class.new(driver, oid) } end |
#path ⇒ Object
217 218 219 |
# File 'lib/capybara/lightpanda/node.rb', line 217 def path call(GET_PATH_JS) end |
#readonly? ⇒ Boolean
209 210 211 |
# File 'lib/capybara/lightpanda/node.rb', line 209 def readonly? call("function() { return this.readOnly }") end |
#rect ⇒ Object
37 38 39 |
# File 'lib/capybara/lightpanda/node.rb', line 37 def rect call(GET_RECT_JS) end |
#right_click(_keys = [], **_options) ⇒ Object
102 103 104 |
# File 'lib/capybara/lightpanda/node.rb', line 102 def right_click(_keys = [], **) call("function() { this.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true})) }") end |
#scroll_by ⇒ Object
127 |
# File 'lib/capybara/lightpanda/node.rb', line 127 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.)
126 |
# File 'lib/capybara/lightpanda/node.rb', line 126 def scroll_to(*); end |
#select_option ⇒ Object
167 168 169 |
# File 'lib/capybara/lightpanda/node.rb', line 167 def select_option call(SELECT_OPTION_JS) end |
#selected? ⇒ Boolean
201 202 203 |
# File 'lib/capybara/lightpanda/node.rb', line 201 def selected? call("function() { return !!this.selected }") end |
#send_keys ⇒ Object
177 178 179 180 |
# File 'lib/capybara/lightpanda/node.rb', line 177 def send_keys(*) call("function() { this.focus() }") driver.browser.keyboard.type(*) end |
#set(value, **_options) ⇒ Object
137 138 139 140 141 142 143 144 145 146 147 148 149 150 |
# File 'lib/capybara/lightpanda/node.rb', line 137 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.
76 77 78 79 80 81 |
# File 'lib/capybara/lightpanda/node.rb', line 76 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
93 94 95 |
# File 'lib/capybara/lightpanda/node.rb', line 93 def style(styles) styles.to_h { |style| [style, call(GET_STYLE_JS, style)] } end |
#tag_name ⇒ Object
182 183 184 185 186 187 188 189 190 191 |
# File 'lib/capybara/lightpanda/node.rb', line 182 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
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’)‘).
133 134 135 |
# File 'lib/capybara/lightpanda/node.rb', line 133 def trigger(event) call(TRIGGER_JS, event.to_s) end |
#unselect_option ⇒ Object
171 172 173 174 175 |
# File 'lib/capybara/lightpanda/node.rb', line 171 def unselect_option return unless call(UNSELECT_OPTION_JS) == "not_multiple" raise Capybara::UnselectNotAllowed, "Cannot unselect option from single select box." end |
#value ⇒ Object
89 90 91 |
# File 'lib/capybara/lightpanda/node.rb', line 89 def value call(GET_VALUE_JS) end |
#visible? ⇒ Boolean
193 194 195 |
# File 'lib/capybara/lightpanda/node.rb', line 193 def visible? call(VISIBLE_JS) end |
#visible_text ⇒ Object
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 |