capybara-lightpanda 0.12.0 → 0.14.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 +35 -0
- data/README.md +1 -1
- data/lib/capybara/lightpanda/browser/console.rb +17 -4
- data/lib/capybara/lightpanda/browser/navigation.rb +14 -16
- data/lib/capybara/lightpanda/browser/runtime.rb +79 -13
- data/lib/capybara/lightpanda/browser/selenium_compat.rb +3 -2
- data/lib/capybara/lightpanda/browser.rb +63 -4
- data/lib/capybara/lightpanda/client/web_socket.rb +44 -21
- data/lib/capybara/lightpanda/client.rb +25 -6
- data/lib/capybara/lightpanda/cookies.rb +45 -5
- data/lib/capybara/lightpanda/driver.rb +16 -7
- data/lib/capybara/lightpanda/javascripts/attach.js +1 -0
- data/lib/capybara/lightpanda/javascripts/predicates.js +41 -2
- data/lib/capybara/lightpanda/keyboard.rb +23 -5
- data/lib/capybara/lightpanda/network.rb +31 -4
- data/lib/capybara/lightpanda/node.rb +102 -28
- data/lib/capybara/lightpanda/options.rb +6 -7
- data/lib/capybara/lightpanda/process.rb +64 -29
- data/lib/capybara/lightpanda/version.rb +1 -1
- metadata +2 -2
|
@@ -79,7 +79,15 @@ module Capybara
|
|
|
79
79
|
# Ferrum/Cuprite spelling: `browser.cookies["session_id"]`.
|
|
80
80
|
alias [] get
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
# Returns whether the browser accepted the cookie (ferrum parity).
|
|
83
|
+
# Lightpanda answers `{success: false}` and drops the cookie for an
|
|
84
|
+
# invalid combination — notably `same_site: "None"` without `secure`
|
|
85
|
+
# since upstream #3529 (build 9877), Chrome's rule — so callers that
|
|
86
|
+
# need it to stick should check the result. A malformed field (a `;` or
|
|
87
|
+
# control character in the name or value, surrounding whitespace) raises
|
|
88
|
+
# BrowserError instead: since upstream #3791 (build 10212) the browser
|
|
89
|
+
# answers `-32602 Sanitizing cookie failed`, as Chrome does.
|
|
90
|
+
def set(name:, value:, domain: nil, path: "/", secure: false, http_only: false, # rubocop:disable Metrics/ParameterLists, Naming/PredicateMethod
|
|
83
91
|
same_site: nil, expires: nil)
|
|
84
92
|
params = {
|
|
85
93
|
name: name,
|
|
@@ -96,7 +104,8 @@ module Capybara
|
|
|
96
104
|
params[:sameSite] = same_site if %w[Strict Lax None].include?(same_site)
|
|
97
105
|
params[:expires] = expires.to_i if expires
|
|
98
106
|
|
|
99
|
-
browser.command("Network.setCookie", **params)
|
|
107
|
+
result = browser.command("Network.setCookie", **params)
|
|
108
|
+
!(result.is_a?(Hash) && result["success"] == false)
|
|
100
109
|
end
|
|
101
110
|
|
|
102
111
|
def remove(name:, domain: nil, path: "/")
|
|
@@ -119,10 +128,15 @@ module Capybara
|
|
|
119
128
|
# Load cookies from a YAML file produced by `store` and re-set them.
|
|
120
129
|
# CDP requires either domain or url for each cookie; entries from `store`
|
|
121
130
|
# already include domain, so they round-trip cleanly. Returns true on
|
|
122
|
-
# success (intentionally not a predicate — mirrors ferrum's API).
|
|
131
|
+
# success (intentionally not a predicate — mirrors ferrum's API). A
|
|
132
|
+
# cookie the browser refuses — `{success: false}`, or a malformed field
|
|
133
|
+
# it rejects outright — is reported on stderr rather than raised, so one
|
|
134
|
+
# bad entry doesn't abort restoring the rest. Builds before 10212 stored
|
|
135
|
+
# malformed cookies, so files saved from them can hold such entries.
|
|
123
136
|
def load(path = "cookies.yml") # rubocop:disable Naming/PredicateMethod
|
|
124
137
|
cookies = YAML.load_file(path)
|
|
125
|
-
cookies.
|
|
138
|
+
rejected = cookies.reject { |c| restore_cookie(c) }.map { |c| c.transform_keys(&:to_s)["name"] }
|
|
139
|
+
warn "Capybara::Lightpanda: the browser refused cookies from #{path}: #{rejected.join(', ')}" if rejected.any?
|
|
126
140
|
true
|
|
127
141
|
end
|
|
128
142
|
|
|
@@ -140,10 +154,36 @@ module Capybara
|
|
|
140
154
|
http_only: attrs["httpOnly"] || false,
|
|
141
155
|
}
|
|
142
156
|
params[:domain] = attrs["domain"] if attrs["domain"]
|
|
143
|
-
|
|
157
|
+
same_site = restored_same_site(attrs)
|
|
158
|
+
params[:same_site] = same_site if same_site
|
|
144
159
|
exp = attrs["expires"]
|
|
145
160
|
params[:expires] = Time.at(exp) if exp.is_a?(Numeric) && exp.positive?
|
|
161
|
+
set_or_refuse(**params)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def set_or_refuse(**params)
|
|
146
165
|
set(**params)
|
|
166
|
+
rescue BrowserError => e
|
|
167
|
+
raise unless e.code == INVALID_PARAMS
|
|
168
|
+
|
|
169
|
+
false
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Chrome's (and, since upstream #3791, Lightpanda's) answer to a cookie
|
|
173
|
+
# whose fields fail sanitizing.
|
|
174
|
+
INVALID_PARAMS = -32_602
|
|
175
|
+
private_constant :INVALID_PARAMS
|
|
176
|
+
|
|
177
|
+
# Lightpanda below build 9877 reported every cookie without a SameSite
|
|
178
|
+
# attribute as "None", so a file stored then is full of insecure
|
|
179
|
+
# SameSite=None cookies — which the browser now rejects (Chrome's rule,
|
|
180
|
+
# upstream #3529). Leaving SameSite unset restores what the cookie
|
|
181
|
+
# actually was: unspecified, i.e. Lax-by-default.
|
|
182
|
+
def restored_same_site(attrs)
|
|
183
|
+
same_site = attrs["sameSite"]
|
|
184
|
+
return nil if same_site == "None" && !attrs["secure"]
|
|
185
|
+
|
|
186
|
+
same_site
|
|
147
187
|
end
|
|
148
188
|
end
|
|
149
189
|
end
|
|
@@ -69,7 +69,7 @@ module Capybara
|
|
|
69
69
|
|
|
70
70
|
def active_element
|
|
71
71
|
oid = browser.active_element
|
|
72
|
-
oid &&
|
|
72
|
+
oid && build_node(oid)
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
# Capybara's Session#send_keys routes to Driver#send_keys; Cuprite's pattern
|
|
@@ -80,25 +80,28 @@ module Capybara
|
|
|
80
80
|
|
|
81
81
|
def find_xpath(selector)
|
|
82
82
|
object_ids = browser.find("xpath", selector)
|
|
83
|
-
object_ids.map { |oid|
|
|
83
|
+
object_ids.map { |oid| build_node(oid) }
|
|
84
84
|
end
|
|
85
85
|
|
|
86
86
|
def find_css(selector)
|
|
87
87
|
object_ids = browser.find("css", selector)
|
|
88
|
-
object_ids.map { |oid|
|
|
88
|
+
object_ids.map { |oid| build_node(oid) }
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
# Inside `within_frame` these run in the frame's document, as in Chrome.
|
|
91
92
|
def evaluate_script(script, *args)
|
|
92
|
-
unwrap_script_result(browser.evaluate(script.strip, *native_args(args)
|
|
93
|
+
unwrap_script_result(browser.evaluate(script.strip, *native_args(args),
|
|
94
|
+
frame: browser.frame_stack.last))
|
|
93
95
|
end
|
|
94
96
|
|
|
95
97
|
def execute_script(script, *args)
|
|
96
|
-
browser.execute(script.strip, *native_args(args))
|
|
98
|
+
browser.execute(script.strip, *native_args(args), frame: browser.frame_stack.last)
|
|
97
99
|
nil
|
|
98
100
|
end
|
|
99
101
|
|
|
100
102
|
def evaluate_async_script(script, *args)
|
|
101
|
-
unwrap_script_result(browser.evaluate_async(script.strip, *native_args(args)
|
|
103
|
+
unwrap_script_result(browser.evaluate_async(script.strip, *native_args(args),
|
|
104
|
+
frame: browser.frame_stack.last))
|
|
102
105
|
end
|
|
103
106
|
|
|
104
107
|
# -- Network Inspection --
|
|
@@ -369,6 +372,12 @@ module Capybara
|
|
|
369
372
|
|
|
370
373
|
private
|
|
371
374
|
|
|
375
|
+
# A node found while a frame is switched to lives in that frame's
|
|
376
|
+
# 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?)
|
|
379
|
+
end
|
|
380
|
+
|
|
372
381
|
# Every window method takes a handle because Capybara's Window objects
|
|
373
382
|
# carry one; with a single window the only valid value is the current
|
|
374
383
|
# target id. Reject anything else rather than silently operating on the
|
|
@@ -414,7 +423,7 @@ module Capybara
|
|
|
414
423
|
when Array then value.map { |v| unwrap_script_result(v) }
|
|
415
424
|
when Hash
|
|
416
425
|
if value.size == 1 && value.key?(Browser::NODE_MARKER)
|
|
417
|
-
|
|
426
|
+
build_node(value[Browser::NODE_MARKER])
|
|
418
427
|
else
|
|
419
428
|
value.transform_values { |v| unwrap_script_result(v) }
|
|
420
429
|
end
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// separately because checkVisibility's defaults (per spec) ignore it.
|
|
18
18
|
//
|
|
19
19
|
// External `<link rel=stylesheet>` ARE fetched and applied — the gem
|
|
20
|
-
// always passes
|
|
20
|
+
// always passes `--load-resources stylesheet` (Lightpanda PR #2487,
|
|
21
21
|
// guaranteed by the build floor) — so stylesheet-driven `display:none`
|
|
22
22
|
// participates in this check. The remaining gap is viewport emulation:
|
|
23
23
|
// `@media` rules and `matchMedia()` evaluate against the hardcoded
|
|
@@ -68,7 +68,9 @@ function isDisabled(el) {
|
|
|
68
68
|
if ((el.tagName || '').toUpperCase() === 'OPTION') {
|
|
69
69
|
var p = el.parentElement;
|
|
70
70
|
while (p) {
|
|
71
|
-
|
|
71
|
+
// `=== true`: a <form>'s `disabled` is named field access, so
|
|
72
|
+
// `<input name="disabled">` makes `form.disabled` that input.
|
|
73
|
+
if (p.disabled === true) return true;
|
|
72
74
|
p = p.parentElement;
|
|
73
75
|
}
|
|
74
76
|
}
|
|
@@ -92,6 +94,43 @@ function isContentEditable(el) {
|
|
|
92
94
|
return false;
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
// The element a real pointer press on `el` would focus: the nearest focusable
|
|
98
|
+
// inclusive ancestor, or null when the press lands on nothing focusable (Chrome
|
|
99
|
+
// then moves focus to the body). Lightpanda's handleClick stopped focusing
|
|
100
|
+
// controls in upstream #3702 (build 9968), and an untrusted `mousedown` — the
|
|
101
|
+
// only kind CLICK_JS can dispatch — never runs the focus default, so the
|
|
102
|
+
// driver has to apply it itself or `click` then `page.send_keys` types nowhere.
|
|
103
|
+
function pointerFocusTarget(el) {
|
|
104
|
+
function isEditingHost(n) {
|
|
105
|
+
if (!n.hasAttribute || !n.hasAttribute('contenteditable')) return false;
|
|
106
|
+
return (n.getAttribute('contenteditable') || '').toLowerCase() !== 'false';
|
|
107
|
+
}
|
|
108
|
+
function isFocusable(n) {
|
|
109
|
+
if (isDisabled(n)) return false;
|
|
110
|
+
switch ((n.tagName || '').toUpperCase()) {
|
|
111
|
+
case 'INPUT': return (n.getAttribute('type') || '').toLowerCase() !== 'hidden';
|
|
112
|
+
case 'SELECT': case 'TEXTAREA': case 'BUTTON': case 'IFRAME': case 'SUMMARY': return true;
|
|
113
|
+
case 'A': case 'AREA': return n.hasAttribute('href');
|
|
114
|
+
}
|
|
115
|
+
return n.hasAttribute('tabindex');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (var n = el; n && n.nodeType === 1; n = n.parentElement) {
|
|
119
|
+
// A press on a <label> lands on its control (the label's activation
|
|
120
|
+
// behavior); the label itself is never focusable.
|
|
121
|
+
if ((n.tagName || '').toUpperCase() === 'LABEL' && n.control) {
|
|
122
|
+
return isFocusable(n.control) ? n.control : null;
|
|
123
|
+
}
|
|
124
|
+
if (isEditingHost(n)) {
|
|
125
|
+
// Focus goes to the outermost element of a contiguous editable region.
|
|
126
|
+
while (n.parentElement && isEditingHost(n.parentElement)) n = n.parentElement;
|
|
127
|
+
return n;
|
|
128
|
+
}
|
|
129
|
+
if (isFocusable(n)) return n;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
95
134
|
// Capybara's "visible text" = WebDriver getText semantics, which native
|
|
96
135
|
// innerText alone doesn't provide at two edges: a non-rendered element must
|
|
97
136
|
// yield "" (innerText returns textContent per the HTML getter's step 1), and a
|
|
@@ -171,19 +171,37 @@ module Capybara
|
|
|
171
171
|
release_modifiers(active_mods)
|
|
172
172
|
end
|
|
173
173
|
|
|
174
|
+
# A chord with Ctrl or Meta is a command, not text: Chrome inserts
|
|
175
|
+
# nothing for Ctrl+A, it selects. Since upstream #3542 (build 9580)
|
|
176
|
+
# Lightpanda inserts whatever the keyDown's `text` carries, so sending
|
|
177
|
+
# `text` with Ctrl held typed a stray "a" into the field. The keyDown
|
|
178
|
+
# still names the key, so shortcut handlers (`e.ctrlKey && e.key ===
|
|
179
|
+
# "a"`) fire exactly as before.
|
|
180
|
+
COMMAND_MODIFIERS = %i[ctrl control meta command].freeze
|
|
181
|
+
private_constant :COMMAND_MODIFIERS
|
|
182
|
+
|
|
174
183
|
def dispatch_modified_char(char, modifier_value, modifiers)
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
184
|
+
key = modifiers.include?(:shift) ? self.class.shifted(char) : char
|
|
185
|
+
definition = { key: key, unmodifiedText: char }
|
|
186
|
+
definition[:text] = key unless modifiers.intersect?(COMMAND_MODIFIERS)
|
|
187
|
+
send_key_event("keyDown", definition, modifiers: modifier_value)
|
|
188
|
+
send_key_event("keyUp", { key: key }, modifiers: modifier_value)
|
|
178
189
|
end
|
|
179
190
|
|
|
180
191
|
def key_definition(key)
|
|
181
192
|
KEYS.fetch(key) { raise ArgumentError, "Unknown key: #{key.inspect}" }
|
|
182
193
|
end
|
|
183
194
|
|
|
195
|
+
# Always `keyDown`, never `rawKeyDown`. Chrome's convention (Ferrum,
|
|
196
|
+
# Puppeteer) is rawKeyDown for keys that carry no `text`, but Lightpanda's
|
|
197
|
+
# Input.dispatchKeyEvent drops rawKeyDown on the floor as a
|
|
198
|
+
# Chrome-internal type (input.zig: `if (params.type == .rawKeyDown)
|
|
199
|
+
# return;`). Under that convention every text-less key — Backspace,
|
|
200
|
+
# Delete, Tab, Escape, the arrows — silently never reached the page,
|
|
201
|
+
# while Enter and Space (which have `text`) did. Lightpanda synthesizes
|
|
202
|
+
# its own keypress from a keyDown, so there is nothing to double-fire.
|
|
184
203
|
def raw_dispatch(definition, modifiers: 0)
|
|
185
|
-
|
|
186
|
-
send_key_event(type, definition, modifiers: modifiers)
|
|
204
|
+
send_key_event("keyDown", definition, modifiers: modifiers)
|
|
187
205
|
send_key_event("keyUp", definition, modifiers: modifiers)
|
|
188
206
|
end
|
|
189
207
|
|
|
@@ -20,7 +20,7 @@ module Capybara
|
|
|
20
20
|
@traffic = []
|
|
21
21
|
@traffic_mutex = Mutex.new
|
|
22
22
|
@enabled = false
|
|
23
|
-
@request_handler = @response_handler = nil
|
|
23
|
+
@request_handler = @response_handler = @failure_handler = nil
|
|
24
24
|
@last_navigation_response = nil
|
|
25
25
|
@document_request_id = nil
|
|
26
26
|
end
|
|
@@ -111,10 +111,10 @@ module Capybara
|
|
|
111
111
|
browser.page_command("Network.setExtraHTTPHeaders", headers: {})
|
|
112
112
|
end
|
|
113
113
|
|
|
114
|
-
# Count of in-flight requests (
|
|
114
|
+
# Count of in-flight requests (no response and no failure recorded yet).
|
|
115
115
|
# Cheap predicate-friendly accessor (ferrum parity).
|
|
116
116
|
def pending_connections
|
|
117
|
-
@traffic_mutex.synchronize { @traffic.count { |t| t
|
|
117
|
+
@traffic_mutex.synchronize { @traffic.count { |t| open?(t) } }
|
|
118
118
|
end
|
|
119
119
|
|
|
120
120
|
# True when no more than `connections` requests are in-flight.
|
|
@@ -158,8 +158,10 @@ module Capybara
|
|
|
158
158
|
def subscribe
|
|
159
159
|
@request_handler = build_request_handler
|
|
160
160
|
@response_handler = build_response_handler
|
|
161
|
+
@failure_handler = build_failure_handler
|
|
161
162
|
browser.on("Network.requestWillBeSent", &@request_handler)
|
|
162
163
|
browser.on("Network.responseReceived", &@response_handler)
|
|
164
|
+
browser.on("Network.loadingFailed", &@failure_handler)
|
|
163
165
|
end
|
|
164
166
|
|
|
165
167
|
# Redirects follow Chrome's shape (Lightpanda since #3175, build ≥8602):
|
|
@@ -184,6 +186,7 @@ module Capybara
|
|
|
184
186
|
method: params.dig("request", "method"),
|
|
185
187
|
timestamp: params["timestamp"],
|
|
186
188
|
response: nil,
|
|
189
|
+
error: nil,
|
|
187
190
|
}
|
|
188
191
|
@traffic_mutex.synchronize do
|
|
189
192
|
if (redirect = params["redirectResponse"]) && (previous = last_open_entry(params["requestId"]))
|
|
@@ -214,9 +217,31 @@ module Capybara
|
|
|
214
217
|
end
|
|
215
218
|
end
|
|
216
219
|
|
|
220
|
+
# A request that never gets a response — refused connection, CORS
|
|
221
|
+
# block, abort — ends with Network.loadingFailed instead. Without this
|
|
222
|
+
# handler its entry stayed open forever, so pending_connections never
|
|
223
|
+
# reached zero and every wait_for_idle burned its full timeout. Routine
|
|
224
|
+
# since Lightpanda enforces CORS by default (upstream #3654, build 9883:
|
|
225
|
+
# a blocked fetch reports errorText "CorsBlocked"). Ferrum closes its
|
|
226
|
+
# exchange on the same event.
|
|
227
|
+
def build_failure_handler
|
|
228
|
+
lambda do |params|
|
|
229
|
+
@traffic_mutex.synchronize do
|
|
230
|
+
request = last_open_entry(params["requestId"])
|
|
231
|
+
next unless request
|
|
232
|
+
|
|
233
|
+
request[:error] = params["errorText"].to_s
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
217
238
|
# Caller holds @traffic_mutex.
|
|
218
239
|
def last_open_entry(request_id)
|
|
219
|
-
@traffic.reverse_each.find { |t| t[:request_id] == request_id && t
|
|
240
|
+
@traffic.reverse_each.find { |t| t[:request_id] == request_id && open?(t) }
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def open?(entry)
|
|
244
|
+
entry[:response].nil? && entry[:error].nil?
|
|
220
245
|
end
|
|
221
246
|
|
|
222
247
|
def response_summary(response)
|
|
@@ -231,8 +256,10 @@ module Capybara
|
|
|
231
256
|
def unsubscribe
|
|
232
257
|
browser.off("Network.requestWillBeSent", @request_handler) if @request_handler
|
|
233
258
|
browser.off("Network.responseReceived", @response_handler) if @response_handler
|
|
259
|
+
browser.off("Network.loadingFailed", @failure_handler) if @failure_handler
|
|
234
260
|
@request_handler = nil
|
|
235
261
|
@response_handler = nil
|
|
262
|
+
@failure_handler = nil
|
|
236
263
|
end
|
|
237
264
|
end
|
|
238
265
|
end
|
|
@@ -8,9 +8,13 @@ module Capybara
|
|
|
8
8
|
|
|
9
9
|
attr_reader :remote_object_id
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
# `frame_scoped`: the node lives in an iframe's document — it was found
|
|
12
|
+
# while a frame was switched to, or from another frame-scoped node. See
|
|
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)
|
|
13
16
|
@remote_object_id = remote_object_id
|
|
17
|
+
@frame_scoped = frame_scoped
|
|
14
18
|
end
|
|
15
19
|
|
|
16
20
|
# Capybara::Driver::Node#native returns the constructor's second argument,
|
|
@@ -265,14 +269,11 @@ module Capybara
|
|
|
265
269
|
# absence then showed up as `current_url` being read against the
|
|
266
270
|
# outgoing document (test/features/keyboard_activation_test.rb).
|
|
267
271
|
#
|
|
268
|
-
#
|
|
269
|
-
#
|
|
270
|
-
#
|
|
271
|
-
# starts. A navigation whose server round-trip exceeds that window is
|
|
272
|
-
# therefore still not awaited here — use Capybara's waiting matchers
|
|
273
|
-
# (`have_current_path`) for those, as you would after a click.
|
|
272
|
+
# `wait_for_idle` sniffs for ~50 ms (Browser::SNIFF_WINDOW) for the
|
|
273
|
+
# main frame to *start* navigating, then waits for the new document —
|
|
274
|
+
# so a slow server round-trip is awaited, as after a click.
|
|
274
275
|
def send_keys(*)
|
|
275
|
-
call(
|
|
276
|
+
call(SEND_KEYS_FOCUS_JS)
|
|
276
277
|
driver.browser.keyboard.type(*)
|
|
277
278
|
driver.browser.wait_for_idle
|
|
278
279
|
end
|
|
@@ -320,17 +321,17 @@ module Capybara
|
|
|
320
321
|
# returned as Lightpanda::Node wrappers. Mirrors Cuprite's `Node#parents`.
|
|
321
322
|
def parents
|
|
322
323
|
oids = driver.browser.parents_of(@remote_object_id)
|
|
323
|
-
oids.map { |oid| self.class.new(driver, oid) }
|
|
324
|
+
oids.map { |oid| self.class.new(driver, oid, frame_scoped: @frame_scoped) }
|
|
324
325
|
end
|
|
325
326
|
|
|
326
327
|
def find_xpath(selector)
|
|
327
328
|
object_ids = driver.browser.find_within(@remote_object_id, "xpath", selector)
|
|
328
|
-
object_ids.map { |oid| self.class.new(driver, oid) }
|
|
329
|
+
object_ids.map { |oid| self.class.new(driver, oid, frame_scoped: @frame_scoped) }
|
|
329
330
|
end
|
|
330
331
|
|
|
331
332
|
def find_css(selector)
|
|
332
333
|
object_ids = driver.browser.find_within(@remote_object_id, "css", selector)
|
|
333
|
-
object_ids.map { |oid| self.class.new(driver, oid) }
|
|
334
|
+
object_ids.map { |oid| self.class.new(driver, oid, frame_scoped: @frame_scoped) }
|
|
334
335
|
end
|
|
335
336
|
|
|
336
337
|
# Equality compares the underlying DOM node via backendNodeId, the only
|
|
@@ -515,7 +516,9 @@ module Capybara
|
|
|
515
516
|
# instead of returning stale data from V8's still-live reference. After
|
|
516
517
|
# a DOM mutation like `replaceWith`, the cached objectId still resolves
|
|
517
518
|
# to the detached node, so reads succeed quietly and Capybara's
|
|
518
|
-
# automatic_reload never re-runs the original query.
|
|
519
|
+
# automatic_reload never re-runs the original query. A frame-scoped node
|
|
520
|
+
# whose document has lost its window is obsolete too (see
|
|
521
|
+
# DISCARDED_DOCUMENT_CHECK).
|
|
519
522
|
def call(function_declaration, *args, return_by_value: true)
|
|
520
523
|
guarded = wrap_with_attached_guard(function_declaration)
|
|
521
524
|
driver.browser.with_default_context_wait do
|
|
@@ -531,12 +534,21 @@ module Capybara
|
|
|
531
534
|
end
|
|
532
535
|
|
|
533
536
|
OBSOLETE_NODE_MARKER = "LIGHTPANDA_OBSOLETE_NODE"
|
|
534
|
-
|
|
537
|
+
|
|
538
|
+
# When an iframe navigates, Chrome destroys the old document's context,
|
|
539
|
+
# so calls on its nodes fail and Capybara re-finds them. Lightpanda keeps
|
|
540
|
+
# the context alive: the node stays `isConnected` and answers from the
|
|
541
|
+
# discarded document, whose `defaultView` is null. Only for frame-scoped
|
|
542
|
+
# nodes: a script-built document (DOMParser, createHTMLDocument) has no
|
|
543
|
+
# window either, and its nodes stay usable. `|| this` covers a Document
|
|
544
|
+
# node, whose ownerDocument is null.
|
|
545
|
+
DISCARDED_DOCUMENT_CHECK = " || (this.ownerDocument || this).defaultView === null"
|
|
546
|
+
private_constant :OBSOLETE_NODE_MARKER, :DISCARDED_DOCUMENT_CHECK
|
|
535
547
|
|
|
536
548
|
def wrap_with_attached_guard(function_declaration)
|
|
537
549
|
<<~JS
|
|
538
550
|
function() {
|
|
539
|
-
if (!this.isConnected) throw new Error(#{OBSOLETE_NODE_MARKER.inspect});
|
|
551
|
+
if (!this.isConnected#{DISCARDED_DOCUMENT_CHECK if @frame_scoped}) throw new Error(#{OBSOLETE_NODE_MARKER.inspect});
|
|
540
552
|
return (#{function_declaration}).apply(this, arguments);
|
|
541
553
|
}
|
|
542
554
|
JS
|
|
@@ -609,28 +621,56 @@ module Capybara
|
|
|
609
621
|
// Cancelling mousedown suppresses focus/text-selection in a real
|
|
610
622
|
// browser but never the click, so the click below is dispatched
|
|
611
623
|
// unconditionally.
|
|
612
|
-
hit.dispatchEvent(new EventCtor('mousedown', { bubbles: true, cancelable: true }));
|
|
624
|
+
var pressed = hit.dispatchEvent(new EventCtor('mousedown', { bubbles: true, cancelable: true }));
|
|
625
|
+
// mousedown's default action moves focus. Ours is untrusted, so the
|
|
626
|
+
// browser won't do it (and since upstream #3702 its click handling
|
|
627
|
+
// doesn't either): focus what the press landed on, or blur the
|
|
628
|
+
// current field when it landed on nothing focusable — Chrome's
|
|
629
|
+
// behavior, and what `click` then `page.send_keys` relies on.
|
|
630
|
+
if (pressed && window._lightpanda) {
|
|
631
|
+
var doc = hit.ownerDocument;
|
|
632
|
+
var focusTarget = _lightpanda.pointerFocusTarget(hit);
|
|
633
|
+
if (focusTarget) {
|
|
634
|
+
if (doc.activeElement !== focusTarget) focusTarget.focus();
|
|
635
|
+
} else if (doc.activeElement && doc.activeElement !== doc.body && doc.activeElement.blur) {
|
|
636
|
+
doc.activeElement.blur();
|
|
637
|
+
}
|
|
638
|
+
}
|
|
613
639
|
hit.dispatchEvent(new EventCtor('mouseup', { bubbles: true, cancelable: true }));
|
|
614
640
|
var clickEvt = new EventCtor('click', { bubbles: true, cancelable: true });
|
|
615
641
|
var notCancelled = hit.dispatchEvent(clickEvt);
|
|
616
642
|
if (!notCancelled || clickEvt.defaultPrevented) return;
|
|
617
643
|
var tag = hit.tagName;
|
|
644
|
+
// A `javascript:` link never navigates: the click's own activation
|
|
645
|
+
// behavior has already run its code. Assigning it to location.href
|
|
646
|
+
// makes Lightpanda request `http://javascript:…`, and since upstream
|
|
647
|
+
// #3843 (build 10261) that failed navigation leaves the page with no
|
|
648
|
+
// execution context — `href="javascript:void(0)"` toggles (select2,
|
|
649
|
+
// Bootstrap) killed the page (lightpanda-io/browser#3898).
|
|
650
|
+
if (tag === 'A' && /^\\s*javascript:/i.test(hit.getAttribute('href') || '')) return;
|
|
618
651
|
if (tag === 'A' && hit.href && hit.target !== '_blank') {
|
|
652
|
+
// Navigate the link's own window, never the bare `window`:
|
|
653
|
+
// Lightpanda runs callFunctionOn in the top document's context
|
|
654
|
+
// even for a node inside an iframe, so `window` would be the top
|
|
655
|
+
// window and a link clicked in a frame replaced the whole page.
|
|
656
|
+
var own = hit.ownerDocument.defaultView || window;
|
|
657
|
+
var target = (hit.target || '').toLowerCase();
|
|
658
|
+
var win = (target === '_top' && own.top) || (target === '_parent' && own.parent) || own;
|
|
619
659
|
// Same-document fragment-only navigation: just update hash (or do
|
|
620
660
|
// nothing if identical). Mirrors Chrome — assigning location.href
|
|
621
661
|
// to a same-document URL on Lightpanda triggers a real navigation
|
|
622
662
|
// tick that cancels pending setTimeout callbacks and clears form
|
|
623
663
|
// values, which breaks any test driving DOM updates from a click
|
|
624
664
|
// handler on `<a href="#...">`.
|
|
625
|
-
var dest = new URL(hit.href,
|
|
626
|
-
var here = new URL(
|
|
665
|
+
var dest = new URL(hit.href, hit.ownerDocument.baseURI);
|
|
666
|
+
var here = new URL(win.location.href);
|
|
627
667
|
if (dest.origin === here.origin && dest.pathname === here.pathname &&
|
|
628
668
|
dest.search === here.search) {
|
|
629
669
|
if (dest.hash !== here.hash) {
|
|
630
|
-
|
|
670
|
+
win.location.hash = dest.hash;
|
|
631
671
|
}
|
|
632
672
|
} else {
|
|
633
|
-
|
|
673
|
+
win.location.href = hit.href;
|
|
634
674
|
}
|
|
635
675
|
}
|
|
636
676
|
}
|
|
@@ -918,8 +958,46 @@ module Capybara
|
|
|
918
958
|
}
|
|
919
959
|
JS
|
|
920
960
|
|
|
961
|
+
# Focuses the element keys are about to be typed into, WebDriver's
|
|
962
|
+
# "Element Send Keys" way (what chromedriver's focus script does): an
|
|
963
|
+
# element that did NOT already have focus gets its caret after its
|
|
964
|
+
# content. Since upstream #3424 (build 9234) `Input.insertText` inserts
|
|
965
|
+
# at the caret, and a value seeded by the `value=` attribute leaves the
|
|
966
|
+
# caret at 0 — without this, `send_keys("x")` on a pre-filled field
|
|
967
|
+
# typed "xfoo" where Chrome types "foox". An element that already has
|
|
968
|
+
# focus keeps its caret, so `page.send_keys` mid-edit continues in place.
|
|
969
|
+
# A non-focusable target (focus() is a no-op on those since upstream
|
|
970
|
+
# #3592, build 9646) must not leave the previous field focused, or its
|
|
971
|
+
# keys land there: blur it so they go to the body, as in Chrome.
|
|
972
|
+
SEND_KEYS_FOCUS_JS = <<~JS
|
|
973
|
+
function() {
|
|
974
|
+
var doc = this.ownerDocument;
|
|
975
|
+
if (doc.activeElement === this) return;
|
|
976
|
+
this.focus();
|
|
977
|
+
if (doc.activeElement !== this) {
|
|
978
|
+
if (doc.activeElement && doc.activeElement !== doc.body && doc.activeElement.blur) doc.activeElement.blur();
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
if (typeof this.setSelectionRange === 'function') {
|
|
982
|
+
// Throws on types without a selection (email, number, …) — those
|
|
983
|
+
// have no caret to place.
|
|
984
|
+
try {
|
|
985
|
+
var end = String(this.value || '').length;
|
|
986
|
+
this.setSelectionRange(end, end);
|
|
987
|
+
} catch (e) {}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
JS
|
|
991
|
+
|
|
992
|
+
# A disabled option (or one in a disabled <optgroup>/<select>) is not
|
|
993
|
+
# selectable by a user, and Capybara's shared spec expects selecting one
|
|
994
|
+
# to be a no-op (rack_test: `return if disabled?`). Both IDL routes —
|
|
995
|
+
# `sel.value =` and `option.selected = true` — select it per spec;
|
|
996
|
+
# Lightpanda's `value` getter used to skip disabled options and hide that,
|
|
997
|
+
# until upstream #3502 (build 9424).
|
|
921
998
|
SELECT_OPTION_JS = <<~JS
|
|
922
999
|
function() {
|
|
1000
|
+
if (_lightpanda.isDisabled(this)) return;
|
|
923
1001
|
var sel = this.parentElement;
|
|
924
1002
|
while (sel && (sel.tagName || '').toUpperCase() !== 'SELECT') sel = sel.parentElement;
|
|
925
1003
|
if (!sel) {
|
|
@@ -929,14 +1007,10 @@ module Capybara
|
|
|
929
1007
|
// that path itself; just no-op here.
|
|
930
1008
|
return;
|
|
931
1009
|
}
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
// `option.selected`, so mirror what a real browser does and
|
|
937
|
-
// route the change through the parent's `value`.
|
|
938
|
-
sel.value = this.value;
|
|
939
|
-
}
|
|
1010
|
+
// Selects exactly this option — not the first one sharing its
|
|
1011
|
+
// value — and, in a single <select>, deselects the rest (upstream
|
|
1012
|
+
// #3375, guaranteed by the floor).
|
|
1013
|
+
this.selected = true;
|
|
940
1014
|
sel.dispatchEvent(new Event('input', {bubbles: true}));
|
|
941
1015
|
sel.dispatchEvent(new Event('change', {bubbles: true}));
|
|
942
1016
|
}
|
|
@@ -47,13 +47,12 @@ module Capybara
|
|
|
47
47
|
# a runaway confirm cancels the action and the spec can still pass. false
|
|
48
48
|
# (default, Cuprite parity) warns on stderr; true raises
|
|
49
49
|
# UnhandledModalError from the action that opened it.
|
|
50
|
-
# load_images:
|
|
51
|
-
# fetched over the network (upstream #3230, build >= 8834
|
|
52
|
-
# default, matching Lightpanda's default of
|
|
53
|
-
# entirely. When on, image requests ride the
|
|
54
|
-
# other — they show in `network.traffic` and
|
|
55
|
-
# `wait_for_network_idle`.
|
|
56
|
-
# UnknownOption at boot, so only enable against binaries that have it.
|
|
50
|
+
# load_images: adds `image` to the `--load-resources` value so `<img>`
|
|
51
|
+
# elements are fetched over the network (upstream #3230, build >= 8834,
|
|
52
|
+
# in the floor). Off by default, matching Lightpanda's default of
|
|
53
|
+
# skipping image fetches entirely. When on, image requests ride the
|
|
54
|
+
# Network domain like any other — they show in `network.traffic` and
|
|
55
|
+
# count toward `wait_for_network_idle`.
|
|
57
56
|
attr_accessor :host, :port, :timeout, :handshake_timeout, :process_timeout,
|
|
58
57
|
:window_size, :browser_path, :headless, :logger, :save_path,
|
|
59
58
|
:raise_on_unhandled_modal, :load_images
|