capybara-lightpanda 0.14.1 → 0.15.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.
@@ -26,11 +26,18 @@ module Capybara
26
26
  connect
27
27
  end
28
28
 
29
- def send_message(message)
29
+ # `timeout` bounds how long the browser may go without taking any of
30
+ # the frame's bytes (see #write); nil falls back to options.timeout.
31
+ def send_message(message, timeout: nil)
30
32
  raise DeadBrowserError, "WebSocket is not open" unless @status == :open
31
33
 
32
34
  @logger&.puts("\n\n▶ #{@logger.elapsed_time} #{message}")
33
- @driver_mutex.synchronize { @driver.text(message) }
35
+ @driver_mutex.synchronize do
36
+ @write_timeout = timeout
37
+ @driver.text(message)
38
+ ensure
39
+ @write_timeout = nil
40
+ end
34
41
  end
35
42
 
36
43
  # The socket is closed even when the connection is already dead. A
@@ -53,8 +60,25 @@ module Capybara
53
60
  @status == :closed || @status == :error
54
61
  end
55
62
 
63
+ # Called by websocket-driver, always under @driver_mutex. A browser
64
+ # that stops reading (hung, stopped, or a page stuck in a sync JS loop
65
+ # on Lightpanda's single thread) would block a plain write forever
66
+ # once the frame outgrows the socket buffers, holding the lock that
67
+ # every other command, the reader's parse and #close need — and the
68
+ # command timeout only starts after the write returns. So the write
69
+ # gives up once the browser takes no byte for the timeout; the wait
70
+ # restarts on every partial write, so a slow but moving peer is fine
71
+ # (ferrum #644). Close and pong frames have no command timeout and
72
+ # fall back to options.timeout rather than waiting forever.
56
73
  def write(data)
57
- @socket.write(data)
74
+ until data.empty?
75
+ written = @socket.write_nonblock(data, exception: false)
76
+ if written.is_a?(Integer)
77
+ data = data.byteslice(written..)
78
+ elsif !wait_for_socket(written)
79
+ abandon_stalled_write
80
+ end
81
+ end
58
82
  rescue Errno::EPIPE, Errno::ECONNRESET, IOError
59
83
  mark_dead
60
84
  end
@@ -71,6 +95,21 @@ module Capybara
71
95
 
72
96
  private
73
97
 
98
+ def wait_for_socket(state)
99
+ timeout = @write_timeout || @options.timeout
100
+ state == :wait_readable ? @socket.wait_readable(timeout) : @socket.wait_writable(timeout)
101
+ end
102
+
103
+ # The frame was cut off midway, so the connection can't be reused:
104
+ # mark it dead (the reader and message threads stop, pending commands
105
+ # are released) and close the socket.
106
+ def abandon_stalled_write
107
+ timeout = @write_timeout || @options.timeout
108
+ mark_dead(:error)
109
+ close_socket
110
+ raise DeadBrowserError, "Browser accepted no data for #{timeout}s and is likely stuck; connection closed"
111
+ end
112
+
74
113
  def close_socket
75
114
  @socket&.close unless @socket.nil? || @socket.closed?
76
115
  rescue IOError, SystemCallError
@@ -175,12 +214,31 @@ module Capybara
175
214
  raise TimeoutError, "WebSocket handshake timed out after #{@options.handshake_timeout}s"
176
215
  end
177
216
 
217
+ # A JS string can hold a lone UTF-16 surrogate ("😀".slice(0, 1));
218
+ # Lightpanda escapes it as `\ud83d`, which Ruby's json rejects.
219
+ # Dropping that frame left its command waiting out the full timeout,
220
+ # so retry once with every lone surrogate replaced by U+FFFD — what
221
+ # Chrome and Ferrum (#648) hand back for the same value.
178
222
  def parse_message(data)
179
223
  JSON.parse(data, max_nesting: false)
180
224
  rescue JSON::ParserError => e
181
- warn_parse_failure(e.message)
225
+ begin
226
+ JSON.parse(replace_lone_surrogates(data), max_nesting: false)
227
+ rescue JSON::ParserError
228
+ warn_parse_failure(e.message)
229
+
230
+ nil
231
+ end
232
+ end
233
+
234
+ # Alternation order matters: a valid lead+trail pair is consumed
235
+ # whole, then a lone surrogate, then any other escape (`\\`, `\"`,
236
+ # a non-surrogate `\uXXXX`) is skipped as-is — scanning left to
237
+ # right so `\\ud800` (an escaped backslash, then text) survives.
238
+ SURROGATE_ESCAPE = /\\u[dD][89abAB]\h{2}\\u[dD][c-fC-F]\h{2}|(\\u[dD][89a-fA-F]\h{2})|\\./m
182
239
 
183
- nil
240
+ def replace_lone_surrogates(data)
241
+ data.gsub(SURROGATE_ESCAPE) { |escape| Regexp.last_match(1) ? "\\uFFFD" : escape }
184
242
  end
185
243
 
186
244
  # Dedupe identical parse-failure warnings per WebSocket instance.
@@ -22,18 +22,18 @@ module Capybara
22
22
 
23
23
  def command(method, params = {}, async: false, session_id: nil, timeout: nil)
24
24
  message = build_message(method, params, session_id: session_id)
25
+ effective_timeout = timeout || @options.timeout
25
26
 
26
27
  if async
27
- @ws.send_message(JSON.generate(message))
28
+ @ws.send_message(JSON.generate(message), timeout: effective_timeout)
28
29
  return true
29
30
  end
30
31
 
31
32
  pending = Concurrent::IVar.new
32
33
  @pendings[message[:id]] = pending
33
34
 
34
- @ws.send_message(JSON.generate(message))
35
+ @ws.send_message(JSON.generate(message), timeout: effective_timeout)
35
36
 
36
- effective_timeout = timeout || @options.timeout
37
37
  response = pending.value!(effective_timeout)
38
38
 
39
39
  if response.nil?
@@ -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).
@@ -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;
@@ -56,13 +55,22 @@ function isObscured(el) {
56
55
  var hit = doc.elementFromPoint(cx, cy);
57
56
  if (!hit) return true;
58
57
  if (hit === el) return false;
59
- return !el.contains(hit);
58
+ if (el.contains(hit)) return false;
59
+ // A hit inside a shadow tree is retargeted to its host, and there is no
60
+ // ShadowRoot.elementFromPoint to look further, so landing on one of el's own
61
+ // hosts is the best "not obscured" answer available.
62
+ var root = el.getRootNode ? el.getRootNode() : doc;
63
+ while (root && root !== doc && root.host) {
64
+ if (hit === root.host) return false;
65
+ root = root.host.getRootNode();
66
+ }
67
+ return true;
60
68
  }
61
69
 
62
70
  // Capybara's `Node#disabled?` is more permissive than CSS `:disabled`:
63
71
  // an `<option>` inside a disabled `<select>` or a disabled `<fieldset>`
64
72
  // 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.
73
+ // per the HTML spec — the Capybara shared specs expect them disabled.
66
74
  function isDisabled(el) {
67
75
  if (el.matches && el.matches(':disabled')) return true;
68
76
  if ((el.tagName || '').toUpperCase() === 'OPTION') {
@@ -153,3 +161,60 @@ function visibleText(el) {
153
161
  }
154
162
  return out;
155
163
  }
164
+
165
+ // Capture intrinsics before page scripts can replace them. Array operations use
166
+ // indexed loops: an overridden map must never detach cache rows from node IDs.
167
+ var hintStringify = JSON.stringify;
168
+ var hintLowerCase = Function.prototype.call.bind(String.prototype.toLowerCase);
169
+
170
+ // Capybara keeps Result candidates for lazy enumeration, possibly after the DOM
171
+ // changes. Only immutable tag names belong in initial_cache; mutable visibility,
172
+ // geometry and styles must be read live. Text hints are a conservative prefilter.
173
+ function findHints(nodes, spec) {
174
+ // Bound eager subtree text walks: nested candidates otherwise make first()
175
+ // quadratic even though Capybara would inspect only the first candidate.
176
+ if (nodes.length > 512) return nodes;
177
+ try {
178
+ var texts = spec.texts || [];
179
+ var needles = [];
180
+ for (var i = 0; i < texts.length; i++) {
181
+ // Ruby splits ASCII whitespace, not every Unicode separator. Unicode
182
+ // casing can also depend on context (Greek final sigma). Fail open.
183
+ if (/[^\x00-\x7F]/.test(texts[i])) return nodes;
184
+ needles[i] = hintLowerCase(texts[i]);
185
+ }
186
+ var kept = [];
187
+ var hints = [];
188
+ for (var j = 0; j < nodes.length; j++) {
189
+ var el = nodes[j];
190
+ var matches = true;
191
+ if (needles.length) {
192
+ var all = el.textContent || '';
193
+ var visible = visibleText(el) || '';
194
+ // The driver's all_text and visible_text normalize Unicode differently.
195
+ // Retain such candidates for Capybara's authoritative text comparison.
196
+ if (!/[^\x00-\x7F]/.test(all + visible)) {
197
+ all = hintLowerCase(all);
198
+ visible = hintLowerCase(visible);
199
+ for (var k = 0; k < needles.length; k++) {
200
+ if (all.indexOf(needles[k]) === -1 && visible.indexOf(needles[k]) === -1) {
201
+ matches = false;
202
+ break;
203
+ }
204
+ }
205
+ }
206
+ }
207
+ if (matches) {
208
+ hints[kept.length] = { tag_name: el.nodeType === 11 ? 'ShadowRoot' :
209
+ (el.tagName ? hintLowerCase(el.tagName) : '') };
210
+ kept[kept.length] = el;
211
+ }
212
+ }
213
+ // Publish only after every read succeeds. Any exception returns the exact
214
+ // original array, without partial filtering or partially constructed hints.
215
+ kept.__lightpandaHints = hintStringify(hints);
216
+ return kept;
217
+ } catch (e) {
218
+ return nodes;
219
+ }
220
+ }
@@ -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