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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +4 -4
- data/lib/capybara/lightpanda/auto_scripts.rb +3 -3
- data/lib/capybara/lightpanda/browser/console.rb +73 -51
- data/lib/capybara/lightpanda/browser/finder.rb +83 -32
- data/lib/capybara/lightpanda/browser/modals.rb +8 -7
- data/lib/capybara/lightpanda/browser/navigation.rb +10 -1
- data/lib/capybara/lightpanda/browser/runtime.rb +3 -3
- data/lib/capybara/lightpanda/browser.rb +13 -4
- data/lib/capybara/lightpanda/client/web_socket.rb +63 -5
- data/lib/capybara/lightpanda/client.rb +7 -4
- data/lib/capybara/lightpanda/cookies.rb +1 -1
- data/lib/capybara/lightpanda/driver.rb +27 -28
- data/lib/capybara/lightpanda/errors.rb +3 -3
- data/lib/capybara/lightpanda/javascripts/attach.js +2 -1
- data/lib/capybara/lightpanda/javascripts/predicates.js +73 -8
- data/lib/capybara/lightpanda/keyboard.rb +15 -5
- data/lib/capybara/lightpanda/network.rb +1 -1
- data/lib/capybara/lightpanda/node.rb +96 -53
- data/lib/capybara/lightpanda/options.rb +9 -7
- data/lib/capybara/lightpanda/process.rb +71 -6
- data/lib/capybara/lightpanda/version.rb +1 -1
- metadata +1 -2
- data/lib/capybara/lightpanda/javascripts/errors.js +0 -74
|
@@ -26,11 +26,18 @@ module Capybara
|
|
|
26
26
|
connect
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|
|
76
|
-
#
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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)
|
|
290
|
-
#
|
|
291
|
-
#
|
|
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 (
|
|
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").
|
|
300
|
-
# driver
|
|
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
|
|
317
|
-
#
|
|
318
|
-
#
|
|
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
|
|
349
|
-
#
|
|
350
|
-
#
|
|
351
|
-
#
|
|
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.
|
|
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.
|
|
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
|
-
# ---
|
|
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
|
|
113
|
-
# NoSuchPageError/StatusError) so suites migrating from
|
|
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
|
|
@@ -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.
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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`).
|
|
95
|
+
# `network.enable` (or `wait_for_network_idle`).
|
|
96
96
|
def headers=(headers)
|
|
97
97
|
enable
|
|
98
98
|
@extra_headers = headers
|