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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -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 +21 -2
- data/lib/capybara/lightpanda/client.rb +4 -1
- data/lib/capybara/lightpanda/cookies.rb +1 -1
- data/lib/capybara/lightpanda/downloads.rb +4 -4
- 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 +81 -9
- data/lib/capybara/lightpanda/keyboard.rb +15 -5
- data/lib/capybara/lightpanda/network.rb +1 -1
- data/lib/capybara/lightpanda/node.rb +114 -71
- 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
|
@@ -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).
|
|
@@ -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
|
-
#
|
|
16
|
-
# type.
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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;
|
|
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;
|
|
@@ -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
|
-
|
|
59
|
-
|
|
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
|
|
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
|
-
|
|
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
|