capybara-lightpanda 0.14.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -149,7 +149,10 @@ module Capybara
149
149
  message = error["message"]
150
150
 
151
151
  case message
152
- when /No node with given id found/i
152
+ # A node from a previous document passed as an *argument* (bound as
153
+ # `this` it answers "Cannot find context" below). Ferrum #647.
154
+ when /No node with given id found/i,
155
+ /Argument should belong to the same JavaScript world as target object/i
153
156
  raise NodeNotFoundError, message
154
157
  when /Cannot find context with specified id/i,
155
158
  /Execution context was destroyed/i,
@@ -76,7 +76,7 @@ module Capybara
76
76
  def get(name)
77
77
  find { |cookie| cookie.name == name }
78
78
  end
79
- # Ferrum/Cuprite spelling: `browser.cookies["session_id"]`.
79
+ # Ferrum spelling: `browser.cookies["session_id"]`.
80
80
  alias [] get
81
81
 
82
82
  # Returns whether the browser accepted the cookie (ferrum parity).
@@ -12,10 +12,10 @@ module Capybara
12
12
  # `Browser.downloadProgress`. We mirror those into a completed-files list +
13
13
  # a blocking `wait`, the way ferrum's Downloads does.
14
14
  #
15
- # IMPORTANT: the trigger is `Content-Disposition: attachment`, NOT MIME
16
- # type. A `text/csv` (or any) response without that header is rendered as a
17
- # normal navigation, not downloaded — which is why Capybara's MIME-triggered
18
- # `:download` shared spec stays in capybara_skip.
15
+ # Navigation downloads require Content-Disposition: attachment, not just a
16
+ # MIME type. New browsers also honor an anchor's download attribute (build
17
+ # 11097 / 1.1 release line); Node#click lets that native activation finish.
18
+ # Capybara's MIME-only :download shared spec still stays in capybara_skip.
19
19
  #
20
20
  # Structure deliberately mirrors Network (same browser ref, same mutex +
21
21
  # subscribe/unsubscribe lifecycle, same reset-after-disposeBrowserContext
@@ -72,20 +72,20 @@ module Capybara
72
72
  oid && build_node(oid)
73
73
  end
74
74
 
75
- # Capybara's Session#send_keys routes to Driver#send_keys; Cuprite's pattern
76
- # is to fan that out to whatever element currently has focus.
75
+ # Capybara's Session#send_keys routes to Driver#send_keys; fan that out
76
+ # to whatever element currently has focus.
77
77
  def send_keys(*keys)
78
78
  active_element&.send_keys(*keys)
79
79
  end
80
80
 
81
- def find_xpath(selector)
82
- object_ids = browser.find("xpath", selector)
83
- object_ids.map { |oid| build_node(oid) }
81
+ # Accepting `**hints` is what makes Capybara send them (it checks the
82
+ # arity); Node.hint_spec turns them into the answers the find returns.
83
+ def find_xpath(selector, **hints)
84
+ browser.find_hinted("xpath", selector, Node.hint_spec(**hints)).map { |oid, cache| build_node(oid, cache) }
84
85
  end
85
86
 
86
- def find_css(selector)
87
- object_ids = browser.find("css", selector)
88
- object_ids.map { |oid| build_node(oid) }
87
+ def find_css(selector, **hints)
88
+ browser.find_hinted("css", selector, Node.hint_spec(**hints)).map { |oid, cache| build_node(oid, cache) }
89
89
  end
90
90
 
91
91
  # Inside `within_frame` these run in the frame's document, as in Chrome.
@@ -107,7 +107,7 @@ module Capybara
107
107
  # -- Network Inspection --
108
108
 
109
109
  # Network tracker (lazily auto-enabled). Exposes `traffic`, `clear`,
110
- # `wait_for_idle`, header overrides, etc. Cuprite parity.
110
+ # `wait_for_idle`, header overrides, etc.
111
111
  def network
112
112
  browser.network
113
113
  end
@@ -124,7 +124,7 @@ module Capybara
124
124
  # Files downloaded since the session started (absolute paths). Capture is
125
125
  # on whenever a destination exists (the :save_path driver option, else
126
126
  # Capybara.save_path) and the server sends `Content-Disposition:
127
- # attachment`. Ferrum/Cuprite expose downloads on the driver too.
127
+ # attachment`. Ferrum exposes downloads on its browser too.
128
128
 
129
129
  def downloads
130
130
  browser.downloads.files
@@ -286,18 +286,18 @@ module Capybara
286
286
  end
287
287
 
288
288
  # capybara-screenshot's fallback for unregistered drivers calls
289
- # `driver.render(path)` (the Cuprite/Ferrum spelling). Without the alias
290
- # every failed test in a capybara-screenshot suite logged
291
- # "Screenshot could not be saved: undefined method 'render'".
289
+ # `driver.render(path)`. Without the alias every failed test in a
290
+ # capybara-screenshot suite logged "Screenshot could not be saved:
291
+ # undefined method 'render'".
292
292
  alias render save_screenshot
293
293
 
294
- # -- Headers (Cuprite-compatible driver surface) --
294
+ # -- Headers (driver surface) --
295
295
  # Delegates to Network, which lazily enables the Network domain. The
296
296
  # overrides do NOT survive reset!: disposing the BrowserContext takes
297
297
  # setExtraHTTPHeaders with it, so Network#reset drops its cached copy
298
298
  # rather than report headers the browser stopped sending (pinned by
299
- # network_test.rb "clears extra_headers"). Cuprite exposes these on the
300
- # driver, and real suites call them there (page.driver.headers = ...).
299
+ # network_test.rb "clears extra_headers"). Real suites call these on the
300
+ # driver (page.driver.headers = ...).
301
301
 
302
302
  def headers
303
303
  browser.network.extra_headers
@@ -313,9 +313,9 @@ module Capybara
313
313
 
314
314
  # -- Lifecycle --
315
315
 
316
- # Thin Cuprite-style wrapper. The interesting work — disposing the
317
- # BrowserContext (cookies, storage, all targets) and starting a fresh
318
- # one — happens in Browser#reset.
316
+ # Thin wrapper. The interesting work — disposing the BrowserContext
317
+ # (cookies, storage, all targets) and starting a fresh one — happens in
318
+ # Browser#reset.
319
319
  #
320
320
  # Rescue is the gem hierarchy plus raw IO escapees only — NOT
321
321
  # StandardError: reset! runs between every test, so a blanket rescue
@@ -345,11 +345,10 @@ module Capybara
345
345
  true
346
346
  end
347
347
 
348
- # Expanded error list for Capybara retry logic (Cuprite pattern).
349
- # MouseEventFailed is in Cuprite's list, but Lightpanda has no
350
- # rendering engine and the gem dispatches clicks through JS — the
351
- # underlying CDP Input.dispatchMouseEvent path doesn't run, so
352
- # MouseEventFailed is never raised.
348
+ # Expanded error list for Capybara retry logic. MouseEventFailed is left
349
+ # out: Lightpanda has no rendering engine and the gem dispatches clicks
350
+ # through JS — the underlying CDP Input.dispatchMouseEvent path doesn't
351
+ # run, so MouseEventFailed is never raised.
353
352
  def invalid_element_errors
354
353
  [
355
354
  NodeNotFoundError,
@@ -374,8 +373,8 @@ module Capybara
374
373
 
375
374
  # A node found while a frame is switched to lives in that frame's
376
375
  # document, which Node#call checks for a frame navigation.
377
- def build_node(oid)
378
- Node.new(self, oid, frame_scoped: browser.frame_stack.any?)
376
+ def build_node(oid, initial_cache = {})
377
+ Node.new(self, oid, initial_cache, frame_scoped: browser.frame_stack.any?)
379
378
  end
380
379
 
381
380
  # Every window method takes a handle because Capybara's Window objects
@@ -391,7 +390,7 @@ module Capybara
391
390
 
392
391
  # Unwrap arguments before sending to the browser. Capybara::Node::Element wraps
393
392
  # our Lightpanda::Node — pull `.base` out so `serialize_argument` can build
394
- # `{objectId: …}` for the CDP payload. Cuprite's `native_args` pattern.
393
+ # `{objectId: …}` for the CDP payload.
395
394
  def native_args(args)
396
395
  args.map { |a| a.is_a?(Capybara::Node::Element) ? a.base : a }
397
396
  end
@@ -399,7 +398,7 @@ module Capybara
399
398
  # Lightpanda's `Network.setCookie` requires either `domain` or `url`
400
399
  # (storage.zig → Cookie.parseDomain). When the caller doesn't supply one,
401
400
  # use the host of the current page if any, else `Capybara.app_host`,
402
- # else loopback. Cuprite parity — lets pre-visit cookie setup just work.
401
+ # else loopback. Lets pre-visit cookie setup just work.
403
402
  def default_domain
404
403
  candidate = (@started && safe_uri_host(browser.current_url)) ||
405
404
  safe_uri_host(Capybara.app_host)
@@ -106,11 +106,11 @@ module Capybara
106
106
  end
107
107
  end
108
108
 
109
- # --- Cuprite/Ferrum drop-in compatibility surface ---
109
+ # --- Drop-in compatibility surface ---
110
110
  # This gem never raises the three classes below (no coordinate-based
111
111
  # mouse path, no multi-page API, no page-status tracking). They mirror
112
- # the peer taxonomy (Cuprite's MouseEventFailed, Ferrum's
113
- # NoSuchPageError/StatusError) so suites migrating from those drivers
112
+ # the error taxonomy of other Capybara CDP drivers (MouseEventFailed, plus
113
+ # Ferrum's NoSuchPageError/StatusError) so suites migrating from them
114
114
  # don't NameError on rescue lists that reference them.
115
115
  class MouseEventFailed < BrowserError
116
116
  attr_reader :node, :selector, :position
@@ -13,5 +13,6 @@ window._lightpanda = {
13
13
  isDisabled: isDisabled,
14
14
  isContentEditable: isContentEditable,
15
15
  pointerFocusTarget: pointerFocusTarget,
16
- visibleText: visibleText
16
+ visibleText: visibleText,
17
+ findHints: findHints
17
18
  };
@@ -19,12 +19,11 @@
19
19
  // External `<link rel=stylesheet>` ARE fetched and applied — the gem
20
20
  // always passes `--load-resources stylesheet` (Lightpanda PR #2487,
21
21
  // guaranteed by the build floor) — so stylesheet-driven `display:none`
22
- // participates in this check. The remaining gap is viewport emulation:
23
- // `@media` rules and `matchMedia()` evaluate against the hardcoded
24
- // 1920×1080 viewport (no resize), so visibility gated on a non-desktop
25
- // viewport — e.g. a mobile-only CTA under `@media (max-width: …)` —
26
- // resolves like desktop Chrome at 1920×1080. Keep those specs on a
27
- // full-layout driver (README's dual-driver setup).
22
+ // participates in this check. `@media` rules evaluate against the
23
+ // emulated viewport (`window_size`, 1920×1080 by default), so a
24
+ // mobile-only CTA under `@media (max-width: …)` needs a narrow
25
+ // `window_size` — or `resize_to` then `visit`, since a loaded document's
26
+ // cascade doesn't re-resolve on resize.
28
27
  function isVisible(el) {
29
28
  if (!el || el.nodeType !== 1) return false;
30
29
  var win = el.ownerDocument.defaultView || window;
@@ -55,14 +54,30 @@ function isObscured(el) {
55
54
  if (cx < 0 || cy < 0 || cx > w || cy > h) return true;
56
55
  var hit = doc.elementFromPoint(cx, cy);
57
56
  if (!hit) return true;
58
- if (hit === el) return false;
59
- return !el.contains(hit);
57
+ // Document hit testing retargets a shadow child to its outermost host.
58
+ // Check each enclosing shadow tree from the outside in: an inner root must
59
+ // never let us see through an overlay covering its host in an outer root.
60
+ var roots = [], root = el.getRootNode ? el.getRootNode() : doc;
61
+ while (root && root !== doc && root.host) {
62
+ roots.push(root);
63
+ root = root.host.getRootNode();
64
+ }
65
+ for (var i = roots.length - 1; i >= 0; i--) {
66
+ root = roots[i];
67
+ if (hit !== root.host) break;
68
+ // Older supported builds have no shadow hit testing. A hit on our own
69
+ // host remains the best available evidence that the child is actionable.
70
+ if (typeof root.elementFromPoint !== 'function') return false;
71
+ hit = root.elementFromPoint(cx, cy);
72
+ if (!hit) return true;
73
+ }
74
+ return hit !== el && !el.contains(hit);
60
75
  }
61
76
 
62
77
  // Capybara's `Node#disabled?` is more permissive than CSS `:disabled`:
63
78
  // an `<option>` inside a disabled `<select>` or a disabled `<fieldset>`
64
79
  // is reported as disabled, even though those don't match CSS `:disabled`
65
- // per the HTML spec. Mirrors Cuprite's behavior so the shared specs pass.
80
+ // per the HTML spec — the Capybara shared specs expect them disabled.
66
81
  function isDisabled(el) {
67
82
  if (el.matches && el.matches(':disabled')) return true;
68
83
  if ((el.tagName || '').toUpperCase() === 'OPTION') {
@@ -153,3 +168,60 @@ function visibleText(el) {
153
168
  }
154
169
  return out;
155
170
  }
171
+
172
+ // Capture intrinsics before page scripts can replace them. Array operations use
173
+ // indexed loops: an overridden map must never detach cache rows from node IDs.
174
+ var hintStringify = JSON.stringify;
175
+ var hintLowerCase = Function.prototype.call.bind(String.prototype.toLowerCase);
176
+
177
+ // Capybara keeps Result candidates for lazy enumeration, possibly after the DOM
178
+ // changes. Only immutable tag names belong in initial_cache; mutable visibility,
179
+ // geometry and styles must be read live. Text hints are a conservative prefilter.
180
+ function findHints(nodes, spec) {
181
+ // Bound eager subtree text walks: nested candidates otherwise make first()
182
+ // quadratic even though Capybara would inspect only the first candidate.
183
+ if (nodes.length > 512) return nodes;
184
+ try {
185
+ var texts = spec.texts || [];
186
+ var needles = [];
187
+ for (var i = 0; i < texts.length; i++) {
188
+ // Ruby splits ASCII whitespace, not every Unicode separator. Unicode
189
+ // casing can also depend on context (Greek final sigma). Fail open.
190
+ if (/[^\x00-\x7F]/.test(texts[i])) return nodes;
191
+ needles[i] = hintLowerCase(texts[i]);
192
+ }
193
+ var kept = [];
194
+ var hints = [];
195
+ for (var j = 0; j < nodes.length; j++) {
196
+ var el = nodes[j];
197
+ var matches = true;
198
+ if (needles.length) {
199
+ var all = el.textContent || '';
200
+ var visible = visibleText(el) || '';
201
+ // The driver's all_text and visible_text normalize Unicode differently.
202
+ // Retain such candidates for Capybara's authoritative text comparison.
203
+ if (!/[^\x00-\x7F]/.test(all + visible)) {
204
+ all = hintLowerCase(all);
205
+ visible = hintLowerCase(visible);
206
+ for (var k = 0; k < needles.length; k++) {
207
+ if (all.indexOf(needles[k]) === -1 && visible.indexOf(needles[k]) === -1) {
208
+ matches = false;
209
+ break;
210
+ }
211
+ }
212
+ }
213
+ }
214
+ if (matches) {
215
+ hints[kept.length] = { tag_name: el.nodeType === 11 ? 'ShadowRoot' :
216
+ (el.tagName ? hintLowerCase(el.tagName) : '') };
217
+ kept[kept.length] = el;
218
+ }
219
+ }
220
+ // Publish only after every read succeeds. Any exception returns the exact
221
+ // original array, without partial filtering or partially constructed hints.
222
+ kept.__lightpandaHints = hintStringify(hints);
223
+ return kept;
224
+ } catch (e) {
225
+ return nodes;
226
+ }
227
+ }
@@ -97,6 +97,11 @@ module Capybara
97
97
  # so JS keydown handlers see the modifier event; CDP's `rawKeyDown` is
98
98
  # documented as "no JS keyDown event is generated" and would hide the
99
99
  # modifier from listeners that count keydown events.
100
+ #
101
+ # Anything that is not a Symbol or an Array is typed as its `to_s`, as
102
+ # Selenium's `Keys.encode` does: `send_keys [1, :tab]` types "1", and
103
+ # `nil` types nothing. Without it an Integer was dropped silently and
104
+ # only the Tab went out.
100
105
  def type(*keys)
101
106
  active_mods = []
102
107
  keys.each do |key|
@@ -107,10 +112,10 @@ module Capybara
107
112
  else
108
113
  dispatch_key_with_mods(key, active_mods)
109
114
  end
110
- when String
111
- key.each_char { |char| dispatch_char_with_mods(char, active_mods) }
112
115
  when Array
113
116
  type_with_modifiers(key)
117
+ else
118
+ type_text(key, active_mods)
114
119
  end
115
120
  end
116
121
  release_modifiers(active_mods)
@@ -163,14 +168,19 @@ module Capybara
163
168
  active_mods = []
164
169
  modifiers.each { |m| press_modifier(m, active_mods) }
165
170
  rest.each do |key|
166
- case key
167
- when Symbol then dispatch_key_with_mods(key, active_mods)
168
- when String then key.each_char { |char| dispatch_char_with_mods(char, active_mods) }
171
+ if key.is_a?(Symbol)
172
+ dispatch_key_with_mods(key, active_mods)
173
+ else
174
+ type_text(key, active_mods)
169
175
  end
170
176
  end
171
177
  release_modifiers(active_mods)
172
178
  end
173
179
 
180
+ def type_text(key, active_mods)
181
+ key.to_s.each_char { |char| dispatch_char_with_mods(char, active_mods) }
182
+ end
183
+
174
184
  # A chord with Ctrl or Meta is a command, not text: Chrome inserts
175
185
  # nothing for Ctrl+A, it selects. Since upstream #3542 (build 9580)
176
186
  # Lightpanda inserts whatever the keyDown's `text` carries, so sending
@@ -92,7 +92,7 @@ module Capybara
92
92
 
93
93
  # Setting extra headers also lazily enables the Network domain. Without
94
94
  # this, headers were silently ignored until the caller separately ran
95
- # `network.enable` (or `wait_for_network_idle`). Cuprite/Ferrum parity.
95
+ # `network.enable` (or `wait_for_network_idle`).
96
96
  def headers=(headers)
97
97
  enable
98
98
  @extra_headers = headers