capybara-lightpanda 0.14.2 → 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 +26 -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/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 +92 -68
- 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
|
@@ -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
|
|
@@ -11,14 +11,22 @@ module Capybara
|
|
|
11
11
|
# `frame_scoped`: the node lives in an iframe's document — it was found
|
|
12
12
|
# while a frame was switched to, or from another frame-scoped node. See
|
|
13
13
|
# #wrap_with_attached_guard for what that changes.
|
|
14
|
-
def initialize(driver, remote_object_id, frame_scoped: false)
|
|
15
|
-
super(driver, remote_object_id)
|
|
14
|
+
def initialize(driver, remote_object_id, initial_cache = {}, frame_scoped: false)
|
|
15
|
+
super(driver, remote_object_id, initial_cache)
|
|
16
16
|
@remote_object_id = remote_object_id
|
|
17
17
|
@frame_scoped = frame_scoped
|
|
18
|
+
@tag_name = initial_cache[:tag_name]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Capybara's finder hints → the spec `_lightpanda.findHints` answers.
|
|
22
|
+
# Only tag_name is cached (when the bounded helper runs); Capybara reads
|
|
23
|
+
# initial_cache[:tag_name] before asking the node.
|
|
24
|
+
def self.hint_spec(texts: nil, **)
|
|
25
|
+
texts&.any? ? { texts: texts.map(&:to_s) } : {}
|
|
18
26
|
end
|
|
19
27
|
|
|
20
28
|
# Capybara::Driver::Node#native returns the constructor's second argument,
|
|
21
|
-
# which here is the raw CDP objectId — a String. So the Selenium
|
|
29
|
+
# which here is the raw CDP objectId — a String. So the Selenium
|
|
22
30
|
# idiom `element.native.send_keys(...)` (solidus's
|
|
23
31
|
# return_authorizations_spec.rb does exactly that) died with
|
|
24
32
|
# "undefined method 'send_keys' for an instance of String". This driver
|
|
@@ -112,7 +120,7 @@ module Capybara
|
|
|
112
120
|
self.class.new(driver, result["objectId"])
|
|
113
121
|
end
|
|
114
122
|
|
|
115
|
-
# Smart property/attribute getter
|
|
123
|
+
# Smart property/attribute getter.
|
|
116
124
|
# Returns resolved URLs for src/href, raw attributes otherwise.
|
|
117
125
|
def [](name)
|
|
118
126
|
call(PROPERTY_OR_ATTRIBUTE_JS, name.to_s)
|
|
@@ -165,10 +173,10 @@ module Capybara
|
|
|
165
173
|
def scroll_to(*); end
|
|
166
174
|
def scroll_by(*); end
|
|
167
175
|
|
|
168
|
-
# Dispatch an arbitrary DOM event by name.
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
#
|
|
176
|
+
# Dispatch an arbitrary DOM event by name. Picks the right Event
|
|
177
|
+
# constructor for known mouse/focus/form names and falls back to a
|
|
178
|
+
# generic Event for everything else (so callers can fire custom events
|
|
179
|
+
# like `node.trigger('lp:custom')`).
|
|
172
180
|
def trigger(event)
|
|
173
181
|
call(TRIGGER_JS, event.to_s)
|
|
174
182
|
end
|
|
@@ -194,10 +202,10 @@ module Capybara
|
|
|
194
202
|
# on this element, so HTML5 dropzones see the payload via
|
|
195
203
|
# `event.dataTransfer`.
|
|
196
204
|
#
|
|
197
|
-
# Files reach the page
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
#
|
|
205
|
+
# Files reach the page through a hidden `<input type=file>`: it is
|
|
206
|
+
# attached to this element's document, `DOM.setFileInputFiles` points it
|
|
207
|
+
# at the paths (the browser reads the bytes off disk itself), and the
|
|
208
|
+
# drop JS moves `input.files` into the
|
|
201
209
|
# DataTransfer and removes the input. Previously the bytes were base64'd
|
|
202
210
|
# into the `Runtime.callFunctionOn` message, which capped a drop at
|
|
203
211
|
# ~70 MB under `--cdp-max-message-size` and pinned every byte in Ruby;
|
|
@@ -216,15 +224,15 @@ module Capybara
|
|
|
216
224
|
end
|
|
217
225
|
|
|
218
226
|
# Maps Capybara's documented drop_modifiers aliases onto the DragEvent
|
|
219
|
-
# init keys (`ctrlKey`, `metaKey`, ...).
|
|
227
|
+
# init keys (`ctrlKey`, `metaKey`, ...).
|
|
220
228
|
DRAG_MODIFIER_ALIASES = { control: :ctrl, command: :meta, cmd: :meta }.freeze
|
|
221
229
|
|
|
222
230
|
# Capybara's `Element#drag_to` — HTML5 half only. HTML5_DRAG_JS replays
|
|
223
|
-
# Capybara's own Selenium HTML5_DRAG_DROP_SCRIPT
|
|
224
|
-
#
|
|
225
|
-
#
|
|
226
|
-
#
|
|
227
|
-
#
|
|
231
|
+
# Capybara's own Selenium HTML5_DRAG_DROP_SCRIPT: dragstart on the
|
|
232
|
+
# draggable ancestor, then dragenter -> 2x dragover -> drop (accepted)
|
|
233
|
+
# or dragleave (refused) -> dragend, setTimeout-paced, sharing one
|
|
234
|
+
# DataTransfer so `setData` in the page's dragstart handler is readable
|
|
235
|
+
# at drop. Runs through `evaluate_async` (the script signals
|
|
228
236
|
# completion via the appended callback), so the drag has fully played out
|
|
229
237
|
# before this method returns.
|
|
230
238
|
#
|
|
@@ -235,8 +243,8 @@ module Capybara
|
|
|
235
243
|
# Selenium presses a real button, then apply the same
|
|
236
244
|
# prevented-or-no-draggable-ancestor test.
|
|
237
245
|
#
|
|
238
|
-
# `steps:`/`scroll:` (
|
|
239
|
-
# ignored so suites migrating from
|
|
246
|
+
# `steps:`/`scroll:` (legacy mouse-path knobs of other CDP drivers) are
|
|
247
|
+
# accepted and ignored so suites migrating from them don't ArgumentError.
|
|
240
248
|
def drag_to(other, html5: nil, delay: 0.05, drop_modifiers: [], **)
|
|
241
249
|
keys = Array(drop_modifiers).map { |m| DRAG_MODIFIER_ALIASES.fetch(m.to_sym, m.to_sym).to_s }
|
|
242
250
|
html5 = !call(LEGACY_DRAG_CHECK_JS) if html5.nil?
|
|
@@ -318,20 +326,18 @@ module Capybara
|
|
|
318
326
|
end
|
|
319
327
|
|
|
320
328
|
# Ancestor chain from `parentNode` up to (but not including) `document`,
|
|
321
|
-
# returned as Lightpanda::Node wrappers.
|
|
329
|
+
# returned as Lightpanda::Node wrappers.
|
|
322
330
|
def parents
|
|
323
331
|
oids = driver.browser.parents_of(@remote_object_id)
|
|
324
332
|
oids.map { |oid| self.class.new(driver, oid, frame_scoped: @frame_scoped) }
|
|
325
333
|
end
|
|
326
334
|
|
|
327
|
-
def find_xpath(selector)
|
|
328
|
-
|
|
329
|
-
object_ids.map { |oid| self.class.new(driver, oid, frame_scoped: @frame_scoped) }
|
|
335
|
+
def find_xpath(selector, **hints)
|
|
336
|
+
find_hinted("xpath", selector, hints)
|
|
330
337
|
end
|
|
331
338
|
|
|
332
|
-
def find_css(selector)
|
|
333
|
-
|
|
334
|
-
object_ids.map { |oid| self.class.new(driver, oid, frame_scoped: @frame_scoped) }
|
|
339
|
+
def find_css(selector, **hints)
|
|
340
|
+
find_hinted("css", selector, hints)
|
|
335
341
|
end
|
|
336
342
|
|
|
337
343
|
# Equality compares the underlying DOM node via backendNodeId, the only
|
|
@@ -368,6 +374,11 @@ module Capybara
|
|
|
368
374
|
|
|
369
375
|
private
|
|
370
376
|
|
|
377
|
+
def find_hinted(method, selector, hints)
|
|
378
|
+
driver.browser.find_within_hinted(@remote_object_id, method, selector, self.class.hint_spec(**hints))
|
|
379
|
+
.map { |oid, cache| self.class.new(driver, oid, cache, frame_scoped: @frame_scoped) }
|
|
380
|
+
end
|
|
381
|
+
|
|
371
382
|
def implicit_submit
|
|
372
383
|
call(IMPLICIT_SUBMIT_JS)
|
|
373
384
|
driver.browser.wait_for_idle
|
|
@@ -446,8 +457,8 @@ module Capybara
|
|
|
446
457
|
|
|
447
458
|
# ISO 8601 week-of-year, "%G" giving the ISO week-numbering year so that
|
|
448
459
|
# the last days of December that belong to week 1 of the next year are
|
|
449
|
-
# rendered with the correct year. Matches
|
|
450
|
-
#
|
|
460
|
+
# rendered with the correct year. Matches what the user would type into
|
|
461
|
+
# a `<input type=week>` field.
|
|
451
462
|
def format_week_value(value)
|
|
452
463
|
return value.to_s if value.is_a?(String) || !value.respond_to?(:to_date)
|
|
453
464
|
|
|
@@ -492,7 +503,7 @@ module Capybara
|
|
|
492
503
|
self.class.new(driver, oid)
|
|
493
504
|
end
|
|
494
505
|
|
|
495
|
-
# Whitespace-normalized text
|
|
506
|
+
# Whitespace-normalized text. Capybara's text matchers compare
|
|
496
507
|
# against this, and Lightpanda's textContent preserves source-template whitespace
|
|
497
508
|
# differently than Chrome — without normalization, multi-line fixtures fail
|
|
498
509
|
# `text: "Line\nLine"` matchers.
|
|
@@ -639,25 +650,13 @@ module Capybara
|
|
|
639
650
|
hit.dispatchEvent(new EventCtor('mouseup', { bubbles: true, cancelable: true }));
|
|
640
651
|
var clickEvt = new EventCtor('click', { bubbles: true, cancelable: true });
|
|
641
652
|
// A link to a scheme the browser can't fetch (mailto:, tel:, sms:…)
|
|
642
|
-
// goes to an external app in Chrome and the page stays.
|
|
643
|
-
// upstream #3940 (
|
|
644
|
-
//
|
|
645
|
-
// b.com (1.0.0), or loses the execution context on a failed `tel:`
|
|
646
|
-
// navigation (10261–10535). Cancel that default from a window
|
|
647
|
-
// listener added for this one dispatch: it runs after the page's
|
|
648
|
-
// own listeners, which still see an uncancelled click.
|
|
653
|
+
// goes to an external app in Chrome and the page stays. The link's
|
|
654
|
+
// own activation is a no-op for it since upstream #3940 (10536, in
|
|
655
|
+
// the floor); don't navigate to it ourselves either.
|
|
649
656
|
var link = hit.closest ? hit.closest('a[href]') : null;
|
|
650
657
|
var scheme = link && /^\\s*([a-z][a-z0-9+.-]*):/i.exec(link.getAttribute('href'));
|
|
651
658
|
var external = !!scheme && !/^(https?|about|blob|data|file|javascript)$/i.test(scheme[1]);
|
|
652
|
-
var
|
|
653
|
-
var keepPage = function (e) { if (e === clickEvt) e.preventDefault(); };
|
|
654
|
-
if (external && view) view.addEventListener('click', keepPage);
|
|
655
|
-
var notCancelled;
|
|
656
|
-
try {
|
|
657
|
-
notCancelled = hit.dispatchEvent(clickEvt);
|
|
658
|
-
} finally {
|
|
659
|
-
if (external && view) view.removeEventListener('click', keepPage);
|
|
660
|
-
}
|
|
659
|
+
var notCancelled = hit.dispatchEvent(clickEvt);
|
|
661
660
|
if (external || !notCancelled || clickEvt.defaultPrevented) return;
|
|
662
661
|
var tag = hit.tagName;
|
|
663
662
|
// A `javascript:` link never navigates: the click's own activation
|
|
@@ -695,9 +694,9 @@ module Capybara
|
|
|
695
694
|
}
|
|
696
695
|
JS
|
|
697
696
|
|
|
698
|
-
#
|
|
699
|
-
#
|
|
700
|
-
#
|
|
697
|
+
# Picks the right Event constructor based on the event name so listeners
|
|
698
|
+
# that key on `event instanceof MouseEvent` / `instanceof SubmitEvent`
|
|
699
|
+
# see what they expect; everything else goes
|
|
701
700
|
# through a generic Event so custom names ("turbo:load", "lp:custom")
|
|
702
701
|
# still dispatch. Each constructor is feature-detected (`typeof X !==
|
|
703
702
|
# 'undefined'`) before use so a missing IDL on Lightpanda falls back
|
|
@@ -715,6 +714,9 @@ module Capybara
|
|
|
715
714
|
event = new FocusEvent(name, init);
|
|
716
715
|
} else if (name === 'submit' && typeof SubmitEvent !== 'undefined') {
|
|
717
716
|
event = new SubmitEvent(name, init);
|
|
717
|
+
} else if (name === 'input' && typeof InputEvent !== 'undefined') {
|
|
718
|
+
// inputType is what typing sends; a plain Event leaves it undefined.
|
|
719
|
+
event = new InputEvent(name, { bubbles: true, cancelable: false, inputType: 'insertText' });
|
|
718
720
|
} else {
|
|
719
721
|
event = new Event(name, init);
|
|
720
722
|
}
|
|
@@ -776,19 +778,45 @@ module Capybara
|
|
|
776
778
|
}
|
|
777
779
|
JS
|
|
778
780
|
|
|
779
|
-
# Ported
|
|
781
|
+
# Ported from Capybara's Selenium driver
|
|
780
782
|
# (capybara/selenium/extensions/html5_drag.rb, HTML5_DRAG_DROP_SCRIPT) —
|
|
781
|
-
#
|
|
782
|
-
#
|
|
783
|
-
#
|
|
784
|
-
#
|
|
783
|
+
# kept close to ease future syncs. Unlike the original, a drag ends the
|
|
784
|
+
# way it does in a browser: `drop` if the target accepted it (cancelled
|
|
785
|
+
# the last dragover), otherwise `dragleave`, never both. Selenium's
|
|
786
|
+
# script fired `dragleave` before `drop`, and Pragmatic drag and drop
|
|
787
|
+
# reads a relatedTarget-less `dragleave` as the drag leaving the window,
|
|
788
|
+
# so the drop landed on nothing. Two port bugs are fixed too:
|
|
789
|
+
# pointOnRect read `rectPt.top` (a DOMPoint has none, so a target
|
|
790
|
+
# entered from above or below got the source's center as its entry
|
|
791
|
+
# point), and `key` leaked as a global. Cuprite made the same changes
|
|
792
|
+
# (rubycdp/cuprite#343). The
|
|
785
793
|
# `source.draggable` reads are native — the HTMLElement.draggable IDL
|
|
786
794
|
# shipped upstream in #3257 (build 8793, guaranteed by the floor; the
|
|
787
795
|
# `_lightpanda.isDraggable` polyfill was retired with that bump).
|
|
788
796
|
# Coordinates come from getBoundingClientRect, which Lightpanda
|
|
789
797
|
# synthesizes without layout — dropzones reading clientX/Y get
|
|
790
798
|
# plausible-but-synthetic (integer since #3259) numbers.
|
|
799
|
+
#
|
|
800
|
+
# One deliberate deviation: the target is centered by
|
|
801
|
+
# `scrollCenterIntoView`, not `scrollIntoView({block: 'center'})`.
|
|
802
|
+
# Lightpanda's scrollIntoView ignores its options and aligns the
|
|
803
|
+
# element's top with the viewport's, and since upstream #3658 (nightly
|
|
804
|
+
# 10661) rects follow the scroll, so a source just above the target
|
|
805
|
+
# left the viewport and the first dragover carried a negative clientY.
|
|
806
|
+
# The helper does what Chrome does (element center at viewport center,
|
|
807
|
+
# clamped to the scrollable range), measuring against the root's rect
|
|
808
|
+
# so it holds whether or not the build's rects follow the scroll.
|
|
809
|
+
# Pinned by `drag_test.rb`.
|
|
791
810
|
HTML5_DRAG_JS = <<~JS
|
|
811
|
+
function scrollCenterIntoView(el) {
|
|
812
|
+
var doc = el.ownerDocument, win = doc.defaultView;
|
|
813
|
+
var rect = el.getBoundingClientRect();
|
|
814
|
+
var root = doc.documentElement.getBoundingClientRect();
|
|
815
|
+
var x = rect.left - root.left + rect.width / 2 - win.innerWidth / 2;
|
|
816
|
+
var y = rect.top - root.top + rect.height / 2 - win.innerHeight / 2;
|
|
817
|
+
win.scrollTo(Math.max(0, x), Math.max(0, y));
|
|
818
|
+
}
|
|
819
|
+
|
|
792
820
|
function rectCenter(rect){
|
|
793
821
|
return new DOMPoint(
|
|
794
822
|
(rect.left + rect.right)/2,
|
|
@@ -813,7 +841,7 @@ module Capybara
|
|
|
813
841
|
}
|
|
814
842
|
|
|
815
843
|
if (pt.y <= rectPt.y) { // top side
|
|
816
|
-
var minYx = (
|
|
844
|
+
var minYx = (rect.top - pt.y) / slope + pt.x;
|
|
817
845
|
if (rect.left <= minYx && minYx <= rect.right)
|
|
818
846
|
return new DOMPoint(minYx, rect.top);
|
|
819
847
|
}
|
|
@@ -828,12 +856,12 @@ module Capybara
|
|
|
828
856
|
}
|
|
829
857
|
|
|
830
858
|
function dragEnterTarget() {
|
|
831
|
-
target
|
|
859
|
+
scrollCenterIntoView(target);
|
|
832
860
|
var targetRect = target.getBoundingClientRect();
|
|
833
861
|
var sourceCenter = rectCenter(source.getBoundingClientRect());
|
|
834
862
|
|
|
835
863
|
for (var i = 0; i < drop_modifier_keys.length; i++) {
|
|
836
|
-
key = drop_modifier_keys[i];
|
|
864
|
+
var key = drop_modifier_keys[i];
|
|
837
865
|
if (key == "control"){
|
|
838
866
|
key = "ctrl"
|
|
839
867
|
}
|
|
@@ -856,18 +884,14 @@ module Capybara
|
|
|
856
884
|
var dragOverOpts = Object.assign({clientX: targetCenter.x, clientY: targetCenter.y}, opts);
|
|
857
885
|
var dragOverEvent = new DragEvent('dragover', dragOverOpts);
|
|
858
886
|
target.dispatchEvent(dragOverEvent);
|
|
859
|
-
window.setTimeout(
|
|
887
|
+
window.setTimeout(dragEnd, step_delay, dragOverEvent.defaultPrevented, dragOverOpts);
|
|
860
888
|
}
|
|
861
889
|
|
|
862
|
-
function
|
|
863
|
-
var
|
|
864
|
-
var
|
|
865
|
-
target.dispatchEvent(
|
|
866
|
-
|
|
867
|
-
var dropEvent = new DragEvent('drop', dragLeaveOptions);
|
|
868
|
-
target.dispatchEvent(dropEvent);
|
|
869
|
-
}
|
|
870
|
-
var dragEndEvent = new DragEvent('dragend', dragLeaveOptions);
|
|
890
|
+
function dragEnd(drop, dragOverOpts) {
|
|
891
|
+
var dragEndOptions = Object.assign({}, opts, dragOverOpts);
|
|
892
|
+
var dropOrLeaveEvent = new DragEvent(drop ? 'drop' : 'dragleave', dragEndOptions);
|
|
893
|
+
target.dispatchEvent(dropOrLeaveEvent);
|
|
894
|
+
var dragEndEvent = new DragEvent('dragend', dragEndOptions);
|
|
871
895
|
source.dispatchEvent(dragEndEvent);
|
|
872
896
|
callback.call(true);
|
|
873
897
|
}
|
|
@@ -1085,7 +1109,7 @@ module Capybara
|
|
|
1085
1109
|
# same node (`node #path returns xpath which points to itself`), so this
|
|
1086
1110
|
# emits `/HTML/BODY/DIV[2]/P[1]` rather than the CSS-ish
|
|
1087
1111
|
# `html > body > div:nth-of-type(2) > p` it used to. Chrome exposes no
|
|
1088
|
-
# native equivalent either
|
|
1112
|
+
# native equivalent either.
|
|
1089
1113
|
#
|
|
1090
1114
|
# Every step is indexed, including single children: `P[1]` and `P` select
|
|
1091
1115
|
# the same node, but the explicit index keeps the output stable if a
|