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
- #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.
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_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
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
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 = [], **) call(CLICK_JS) driver.browser.wait_for_idle end |
#disabled? ⇒ 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 = [], **) 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 |
#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).
268 269 270 |
# File 'lib/capybara/lightpanda/node.rb', line 268 def hash backend_node_id.hash end |
#hover ⇒ Object
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.
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
221 222 223 |
# File 'lib/capybara/lightpanda/node.rb', line 221 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`.
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 |
#path ⇒ Object
225 226 227 |
# File 'lib/capybara/lightpanda/node.rb', line 225 def path call(GET_PATH_JS) end |
#readonly? ⇒ Boolean
217 218 219 |
# File 'lib/capybara/lightpanda/node.rb', line 217 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
104 105 106 |
# File 'lib/capybara/lightpanda/node.rb', line 104 def right_click(_keys = [], **) call("function() { this.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true})) }") end |
#scroll_by ⇒ Object
129 |
# File 'lib/capybara/lightpanda/node.rb', line 129 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.)
128 |
# File 'lib/capybara/lightpanda/node.rb', line 128 def scroll_to(*); end |
#select_option ⇒ Object
169 170 171 |
# File 'lib/capybara/lightpanda/node.rb', line 169 def select_option call(SELECT_OPTION_JS) end |
#selected? ⇒ Boolean
209 210 211 |
# File 'lib/capybara/lightpanda/node.rb', line 209 def selected? call("function() { return !!this.selected }") end |
#send_keys ⇒ Object
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, **) 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
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_name ⇒ Object
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 |
#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’)‘).
135 136 137 |
# File 'lib/capybara/lightpanda/node.rb', line 135 def trigger(event) call(TRIGGER_JS, event.to_s) end |
#unselect_option ⇒ Object
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 |
#value ⇒ Object
91 92 93 |
# File 'lib/capybara/lightpanda/node.rb', line 91 def value call(GET_VALUE_JS) end |
#visible? ⇒ Boolean
201 202 203 |
# File 'lib/capybara/lightpanda/node.rb', line 201 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 |