capybara-lightpanda 0.13.0 → 0.14.1
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 +21 -0
- 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 +127 -5
- data/lib/capybara/lightpanda/cookies.rb +21 -3
- data/lib/capybara/lightpanda/driver.rb +16 -7
- data/lib/capybara/lightpanda/javascripts/predicates.js +3 -1
- data/lib/capybara/lightpanda/node.rb +44 -18
- data/lib/capybara/lightpanda/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d38fc6a0a5cbba01bba3a0a1363d8e0577dc99c2cb8a77b81f407b273a91863b
|
|
4
|
+
data.tar.gz: 21cdea0aeba27736c52f800c22e850d80ea15d3c7bb1c6c9b173421bb227be7d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ec4a2743650df3602c43fc393a82af00c19c5b44d59fab31156aed0b3252e8fb7f3c5f06df1b6e2fe994c9b974a1b21b29707c22c6bff571d4c2b6aca195add4
|
|
7
|
+
data.tar.gz: ba19fbd3c2754eae2b6d3af02584d739eba498b93e0e0ff392f9dc7a9feefdc1af1ff68081cd5b6963ca41c768cf8cebcf3b48b9debd9923e5142b15ac1c9223
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.14.1] - 2026-10-12
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **A click that loads a new page waits for that page's scripts.** `click` used to return as soon as the new document existed, before its deferred and module scripts had run. Apps load Stimulus, Turbo and Bootstrap that way, so the next action could land on a page whose handlers were not wired yet: a Bootstrap collapse that did not open, a `confirm()` that never showed (`accept_confirm` raised `ModalNotFound`) while the form was submitted anyway, a form posted before its JavaScript was ready. The click now also waits for `DOMContentLoaded`, as Cuprite waits for the load event. `send_keys`, `go_back` and `go_forward` wait the same way when they load a new page. Clicks that don't navigate are as fast as before.
|
|
8
|
+
- **A click on a link that Turbo hands back to the browser waits for the new page.** With Turbo on the page, such a click returned immediately on the page being left, because Turbo reported activity and went idle again before the navigation started.
|
|
9
|
+
|
|
10
|
+
## [0.14.0] - 2026-10-11
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`evaluate_script` returns `Capybara::Lightpanda::CyclicObject` for a result that references itself, as Cuprite returns `Ferrum::CyclicObject`.** A self-referencing object used to raise `BrowserError: Object reference chain is too long`. So did `evaluate_script("window")` on recent Lightpanda nightlies, and on older builds it came back as a broken element.
|
|
15
|
+
- **`Cookies#load` keeps restoring a file when one cookie is malformed.** Recent Lightpanda nightlies reject a cookie with a `;` or control character in its name or value, as Chrome does, where older builds stored it. A file saved from an older build could hold such a cookie, and `load` stopped at it with a `BrowserError`. It now skips that cookie and names it in its warning, like any other cookie the browser refuses. `Cookies#set` still raises for it.
|
|
16
|
+
- **Clicking a `javascript:` link no longer breaks the page on recent Lightpanda nightlies.** On nightly 10261 and later, clicking `<a href="javascript:void(0)">` (select2, Bootstrap toggles, older jQuery code) left the page unusable: every later `find` or `evaluate_script` raised `NoExecutionContextError` until the next `visit`. The link's code still runs, once, and the page stays.
|
|
17
|
+
- **A click waits for the page it navigates to, even when the server is slow.** `click` used to return after about 50 ms, before a link, `window.location` assignment or form submission had loaded the next page whenever the server took longer than that (a slow action, a redirect). The very next `current_url` or `assert_current_path(..., wait: 0)` then read the page being left. The click now waits for the new page, as in Cuprite. Clicks that don't navigate are as fast as before, and so are `pushState` and anchor clicks. A navigation that fails, such as a refused connection, doesn't make the click wait for the timeout. `send_keys` waits the same way when Enter submits a form.
|
|
18
|
+
- **`go_back` and `go_forward` keep the page when the history entry comes from `pushState`.** They used to reload the document from the server, so the page's JavaScript state was lost and no `popstate` event fired: a "browser back button" test on a step-by-step page could not pass. They now behave like the browser's buttons: the page stays, `popstate` fires and `current_url` is up to date when the call returns. Going back or forward to another page still loads it, and the call waits for it. Lightpanda limit: on Lightpanda 1.0.0 this only applies when the two entries differ by their `#fragment`. When `pushState` changed the path (Turbo Drive, client-side routers), 1.0.0 still reloads the page ([lightpanda-io/browser#3726](https://github.com/lightpanda-io/browser/issues/3726)); nightly builds from 10239 keep it.
|
|
19
|
+
- **Clicking a link inside an iframe navigates the iframe, not the whole page.** Inside `within_frame`, `click_link` used to replace the whole page with the link's target, and a `#fragment` link in a frame did the same. Links with `target="_top"` or `target="_parent"` still leave the frame, as in Chrome.
|
|
20
|
+
- **Scripts run inside the frame within `within_frame`.** `execute_script`, `evaluate_script` and `evaluate_async_script` used to run against the page around the frame, so `document` and `window` were the top page's. They now see the frame's document, as in Chrome.
|
|
21
|
+
- **An element found in an iframe goes stale when that iframe navigates.** It used to keep answering from the old document: empty text, not visible, clicks that did nothing. Capybara never found it again. It is now treated as obsolete, as in Chrome, and Capybara's automatic reload finds the element in the new page.
|
|
22
|
+
- **`select` works in a form that has a field named `disabled`.** On recent Lightpanda nightlies, an `<input name="disabled">` made every option in its form read as disabled, so `select` silently did nothing.
|
|
23
|
+
|
|
3
24
|
## [0.13.0] - 2026-10-03
|
|
4
25
|
|
|
5
26
|
> **Update Lightpanda before upgrading.** This release requires Lightpanda **1.0.0** or a nightly build ≥ 9994. The driver refuses anything older and names the version it found. Tagged releases work again: pin `1.0.0` for a reproducible browser.
|
|
@@ -33,12 +33,20 @@ module Capybara
|
|
|
33
33
|
end
|
|
34
34
|
alias goto go_to
|
|
35
35
|
|
|
36
|
+
# Back/forward go through the page's own History API, like the
|
|
37
|
+
# browser's buttons: an entry created by `pushState` is restored in
|
|
38
|
+
# the same document (JS state kept, `popstate` fired synchronously),
|
|
39
|
+
# while an entry from another document starts a real navigation that
|
|
40
|
+
# `wait_for_idle` sniffs and waits out. CDP's
|
|
41
|
+
# `Page.navigateToHistoryEntry` is not an option — Lightpanda reloads
|
|
42
|
+
# the document even for a `pushState` entry. Both are no-ops at either
|
|
43
|
+
# end of the session history.
|
|
36
44
|
def back
|
|
37
|
-
|
|
45
|
+
traverse_history("history.back()")
|
|
38
46
|
end
|
|
39
47
|
|
|
40
48
|
def forward
|
|
41
|
-
|
|
49
|
+
traverse_history("history.forward()")
|
|
42
50
|
end
|
|
43
51
|
|
|
44
52
|
def refresh
|
|
@@ -98,23 +106,13 @@ module Capybara
|
|
|
98
106
|
await_navigation(&)
|
|
99
107
|
end
|
|
100
108
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
# entry's `id`. No-op when the offset would step past either end so
|
|
105
|
-
# the behavior matches `history.back()` / `history.forward()` on a
|
|
106
|
-
# bounded session history.
|
|
107
|
-
def navigate_history(offset)
|
|
108
|
-
history = page_command("Page.getNavigationHistory")
|
|
109
|
-
target_index = history["currentIndex"] + offset
|
|
110
|
-
entries = history["entries"]
|
|
111
|
-
return if target_index.negative? || target_index >= entries.length
|
|
112
|
-
|
|
113
|
-
page_command("Page.navigateToHistoryEntry", entryId: entries[target_index]["id"])
|
|
109
|
+
def traverse_history(expression)
|
|
110
|
+
page_command("Runtime.evaluate", expression: expression)
|
|
111
|
+
wait_for_idle
|
|
114
112
|
end
|
|
115
113
|
|
|
116
114
|
# Common navigation lifecycle shared by `wait_for_page_load` (fresh
|
|
117
|
-
# `Page.navigate`) and `wait_for_navigation` (
|
|
115
|
+
# `Page.navigate`) and `wait_for_navigation` (reload).
|
|
118
116
|
# Subscribes to Page.loadEventFired, runs the trigger, waits briefly for
|
|
119
117
|
# the event, falls back to readyState polling for the remaining budget.
|
|
120
118
|
# The handler is unsubscribed via `ensure` so a raising trigger doesn't
|
|
@@ -1,7 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "singleton"
|
|
4
|
+
|
|
3
5
|
module Capybara
|
|
4
6
|
module Lightpanda
|
|
7
|
+
# Stands in for a script result that has no Ruby representation because it
|
|
8
|
+
# references itself — `window`, or `a.self = a` — so `returnByValue` can't
|
|
9
|
+
# serialize it. Ferrum returns its own `Ferrum::CyclicObject` the same way.
|
|
10
|
+
class CyclicObject
|
|
11
|
+
include Singleton
|
|
12
|
+
|
|
13
|
+
def inspect
|
|
14
|
+
%(#<#{self.class} JavaScript object that cannot be represented in Ruby>)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
5
18
|
class Browser
|
|
6
19
|
# JS evaluation and RemoteObject plumbing: Runtime.evaluate /
|
|
7
20
|
# callFunctionOn dispatch, result serialization (Ferrum's
|
|
@@ -23,7 +36,15 @@ module Capybara
|
|
|
23
36
|
# later calls, like the DevTools console) but allows redeclaration.
|
|
24
37
|
# Completion-value semantics cover a bare expression (`'foo'`), a
|
|
25
38
|
# `throw` statement, and multi-statement scripts alike.
|
|
26
|
-
|
|
39
|
+
#
|
|
40
|
+
# `frame` (an `<iframe>` Node, `frame_stack.last` inside `within_frame`)
|
|
41
|
+
# runs the script against that frame's globals — see call_with_args.
|
|
42
|
+
# There the script goes through the frame's indirect `eval` rather than
|
|
43
|
+
# REPL mode: same completion-value semantics, `var` still lands on the
|
|
44
|
+
# frame's window, but a top-level `const`/`let` lives only for its call.
|
|
45
|
+
def evaluate(expression, *args, frame: nil)
|
|
46
|
+
return call_with_args(FRAME_EVAL_JS, [expression], frame: frame) if frame && args.empty?
|
|
47
|
+
|
|
27
48
|
if args.empty?
|
|
28
49
|
response = page_command("Runtime.evaluate", expression: expression, returnByValue: false,
|
|
29
50
|
awaitPromise: true, replMode: true)
|
|
@@ -33,7 +54,7 @@ module Capybara
|
|
|
33
54
|
end
|
|
34
55
|
|
|
35
56
|
wrapped = "function() { return #{expression} }"
|
|
36
|
-
call_with_args(wrapped, args)
|
|
57
|
+
call_with_args(wrapped, args, frame: frame)
|
|
37
58
|
end
|
|
38
59
|
|
|
39
60
|
# Execute JS without returning a value.
|
|
@@ -43,7 +64,12 @@ module Capybara
|
|
|
43
64
|
# on JS exceptions so silent failures don't mask test bugs (the
|
|
44
65
|
# previous fast path swallowed them because `awaitPromise: false` was
|
|
45
66
|
# checked but `exceptionDetails` was not).
|
|
46
|
-
def execute(expression, *args)
|
|
67
|
+
def execute(expression, *args, frame: nil)
|
|
68
|
+
if frame && args.empty?
|
|
69
|
+
call_with_args(FRAME_EXECUTE_JS, [expression], return_by_value: true, frame: frame)
|
|
70
|
+
return nil
|
|
71
|
+
end
|
|
72
|
+
|
|
47
73
|
if args.empty?
|
|
48
74
|
response = page_command("Runtime.evaluate", expression: expression, returnByValue: false,
|
|
49
75
|
awaitPromise: false, replMode: true)
|
|
@@ -52,7 +78,7 @@ module Capybara
|
|
|
52
78
|
end
|
|
53
79
|
|
|
54
80
|
wrapped = "function() { #{expression} }"
|
|
55
|
-
call_with_args(wrapped, args, return_by_value: false)
|
|
81
|
+
call_with_args(wrapped, args, return_by_value: false, frame: frame)
|
|
56
82
|
nil
|
|
57
83
|
end
|
|
58
84
|
|
|
@@ -77,7 +103,7 @@ module Capybara
|
|
|
77
103
|
# Evaluate async JS with a callback. The user's script receives
|
|
78
104
|
# the callback as its last argument (`arguments[arguments.length - 1]`),
|
|
79
105
|
# matching Capybara's evaluate_async_script contract.
|
|
80
|
-
def evaluate_async(expression, *args, wait: @options.timeout)
|
|
106
|
+
def evaluate_async(expression, *args, wait: @options.timeout, frame: nil)
|
|
81
107
|
timeout_ms = (wait * 1000).to_i
|
|
82
108
|
wrapped = <<~JS
|
|
83
109
|
function() {
|
|
@@ -92,7 +118,7 @@ module Capybara
|
|
|
92
118
|
});
|
|
93
119
|
}
|
|
94
120
|
JS
|
|
95
|
-
call_with_args(wrapped, args)
|
|
121
|
+
call_with_args(wrapped, args, frame: frame)
|
|
96
122
|
end
|
|
97
123
|
|
|
98
124
|
# Evaluate JS and return a RemoteObject reference (for DOM nodes, arrays).
|
|
@@ -142,6 +168,19 @@ module Capybara
|
|
|
142
168
|
# itself timed out / failed in transport.
|
|
143
169
|
end
|
|
144
170
|
|
|
171
|
+
# Compiles the declaration (first argument) with the frame's `eval` and
|
|
172
|
+
# applies it to the rest. `this` is the `<iframe>` element.
|
|
173
|
+
FRAME_APPLY_JS = <<~JS
|
|
174
|
+
function(declaration) {
|
|
175
|
+
var fn = this.contentWindow.eval('(' + declaration + ')');
|
|
176
|
+
return fn.apply(null, Array.prototype.slice.call(arguments, 1));
|
|
177
|
+
}
|
|
178
|
+
JS
|
|
179
|
+
# Indirect eval: global code in the frame, like a classic script.
|
|
180
|
+
FRAME_EVAL_JS = "function(source) { return (0, eval)(source); }"
|
|
181
|
+
# No return, so a script ending in a pending Promise isn't awaited.
|
|
182
|
+
FRAME_EXECUTE_JS = "function(source) { (0, eval)(source); }"
|
|
183
|
+
|
|
145
184
|
private
|
|
146
185
|
|
|
147
186
|
def serialize_argument(arg)
|
|
@@ -167,17 +206,32 @@ module Capybara
|
|
|
167
206
|
# When `return_by_value: false` (the default) the return value is unwrapped via
|
|
168
207
|
# `unwrap_call_result` so that DOM nodes come back as `{ "__lightpanda_node__" => ... }`
|
|
169
208
|
# hashes the Driver can wrap as Capybara nodes.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
209
|
+
#
|
|
210
|
+
# With `frame`, the function is compiled by the frame's own `eval`, so
|
|
211
|
+
# its free names (`window`, `document`, `location`) are the frame's, and
|
|
212
|
+
# called from the `<iframe>` element. Running it in the frame's own
|
|
213
|
+
# execution context instead would not do: Lightpanda runs every
|
|
214
|
+
# callFunctionOn in the top context, so node handles found inside a
|
|
215
|
+
# frame belong to that context and can't be passed into another
|
|
216
|
+
# ("Argument should belong to the same JavaScript world").
|
|
217
|
+
def call_with_args(function_declaration, args, return_by_value: false, frame: nil)
|
|
218
|
+
arguments = args.map { |a| serialize_argument(a) }
|
|
219
|
+
if frame
|
|
220
|
+
target = frame.remote_object_id
|
|
221
|
+
arguments.unshift({ value: function_declaration })
|
|
222
|
+
function_declaration = FRAME_APPLY_JS
|
|
223
|
+
else
|
|
224
|
+
# document_object_id returns a fresh RemoteObject handle every call.
|
|
225
|
+
# Release it on the way out so long-running shared-spec sessions
|
|
226
|
+
# don't accumulate orphaned V8 handles between resets.
|
|
227
|
+
target = doc_oid = document_object_id
|
|
228
|
+
end
|
|
175
229
|
params = {
|
|
176
|
-
objectId:
|
|
230
|
+
objectId: target,
|
|
177
231
|
functionDeclaration: function_declaration,
|
|
178
232
|
returnByValue: return_by_value,
|
|
179
233
|
awaitPromise: true,
|
|
180
|
-
arguments:
|
|
234
|
+
arguments: arguments,
|
|
181
235
|
}
|
|
182
236
|
response = page_command("Runtime.callFunctionOn", **params)
|
|
183
237
|
raise_on_js_error!("call_with_args", function_declaration, response)
|
|
@@ -216,6 +270,11 @@ module Capybara
|
|
|
216
270
|
# Re-fetch a remote object as JSON-serializable value for plain objects/arrays.
|
|
217
271
|
# Cheaper than walking properties and good enough for shared specs. Releases
|
|
218
272
|
# the original handle so long-lived sessions don't accumulate leaked objectIds.
|
|
273
|
+
#
|
|
274
|
+
# V8 refuses to return a cyclic object by value. Through 1.0.0 every
|
|
275
|
+
# Zig-backed object, `window` included, came back as a node; since
|
|
276
|
+
# upstream #3831 (build 10255) `window` lands here and raised. Both it
|
|
277
|
+
# and a plain self-referencing object now return CyclicObject.
|
|
219
278
|
def serialize_remote_object(object_id)
|
|
220
279
|
json = page_command(
|
|
221
280
|
"Runtime.callFunctionOn",
|
|
@@ -224,10 +283,17 @@ module Capybara
|
|
|
224
283
|
returnByValue: true
|
|
225
284
|
)
|
|
226
285
|
handle_evaluate_response(json, "function() { return this }")
|
|
286
|
+
rescue BrowserError => e
|
|
287
|
+
raise unless CYCLIC_VALUE_ERRORS.any? { |message| e.message.to_s.include?(message) }
|
|
288
|
+
|
|
289
|
+
CyclicObject.instance
|
|
227
290
|
ensure
|
|
228
291
|
release_object(object_id)
|
|
229
292
|
end
|
|
230
293
|
|
|
294
|
+
CYCLIC_VALUE_ERRORS = ["Object reference chain is too long", "Object couldn't be returned by value"].freeze
|
|
295
|
+
private_constant :CYCLIC_VALUE_ERRORS
|
|
296
|
+
|
|
231
297
|
# Walk an array's own indexed properties via `Runtime.getProperties`,
|
|
232
298
|
# unwrapping each element through the regular result pipeline so that
|
|
233
299
|
# DOM-node entries surface as `{ "__lightpanda_node__" => ... }` instead
|
|
@@ -85,9 +85,10 @@ module Capybara
|
|
|
85
85
|
# Selenium's `execute_async_script`: the script receives a completion
|
|
86
86
|
# callback as its last argument. Same contract as
|
|
87
87
|
# Driver#evaluate_async_script, minus the DOM-node unwrapping — callers
|
|
88
|
-
# on this path (axe-core et al.) hand back plain JSON.
|
|
88
|
+
# on this path (axe-core et al.) hand back plain JSON. Runs in the
|
|
89
|
+
# current frame, as Selenium's does.
|
|
89
90
|
def execute_async_script(script, *)
|
|
90
|
-
evaluate_async(script.to_s.strip,
|
|
91
|
+
evaluate_async(script.to_s.strip, *, frame: frame_stack.last)
|
|
91
92
|
end
|
|
92
93
|
|
|
93
94
|
# Selenium's raw-CDP escape hatch, as used by
|
|
@@ -166,6 +166,8 @@ module Capybara
|
|
|
166
166
|
subscribe_to_console_logs
|
|
167
167
|
subscribe_to_console_capture
|
|
168
168
|
subscribe_to_execution_context
|
|
169
|
+
subscribe_to_navigation
|
|
170
|
+
subscribe_to_document_ready
|
|
169
171
|
subscribe_to_turbo_signals
|
|
170
172
|
# Network owns the Network.* domain: enabling installs traffic
|
|
171
173
|
# tracking AND the navigation-response capture behind status_code.
|
|
@@ -423,24 +425,39 @@ module Capybara
|
|
|
423
425
|
# (title, current_url, …) without racing the navigation lifecycle.
|
|
424
426
|
#
|
|
425
427
|
# Sniff window: the action returns synchronously, but the CDP events
|
|
426
|
-
# signalling its async fallout
|
|
427
|
-
#
|
|
428
|
-
#
|
|
429
|
-
# the window, assume the action was inert and exit
|
|
428
|
+
# signalling its async fallout arrive later on the dispatch thread. We
|
|
429
|
+
# poll briefly for either of them — a main-frame navigation start (see
|
|
430
|
+
# subscribe_to_navigation) or Runtime.executionContextsCleared — and if
|
|
431
|
+
# none fires within the window, assume the action was inert and exit
|
|
432
|
+
# fast. Lightpanda announces a navigation ~5 ms after the click but only
|
|
433
|
+
# swaps the execution context once the response lands, so watching the
|
|
434
|
+
# context alone returned on the old document whenever the server took
|
|
435
|
+
# longer than the window. The turbo sentinel does not end the sniff
|
|
436
|
+
# (it is awaited after): Turbo goes busy then idle within a few ms on a
|
|
437
|
+
# link it hands back to the browser, before the navigation start lands.
|
|
430
438
|
SNIFF_WINDOW = 0.05
|
|
431
439
|
private_constant :SNIFF_WINDOW
|
|
432
440
|
|
|
433
441
|
def wait_for_idle
|
|
434
442
|
prior_context_iteration = @default_context_event.iteration
|
|
443
|
+
prior_navigation_iteration = @navigation_event.iteration
|
|
435
444
|
sniff_deadline = monotonic_time + SNIFF_WINDOW
|
|
445
|
+
navigating = false
|
|
436
446
|
loop do
|
|
447
|
+
navigating = @navigation_event.iteration > prior_navigation_iteration
|
|
448
|
+
break if navigating
|
|
437
449
|
break if @default_context_event.iteration > prior_context_iteration
|
|
438
|
-
break unless @turbo_event.set?
|
|
439
450
|
break if monotonic_time > sniff_deadline
|
|
440
451
|
|
|
441
452
|
sleep 0.001
|
|
442
453
|
end
|
|
443
454
|
|
|
455
|
+
# Only a navigation this action started is awaited: one left pending
|
|
456
|
+
# by an earlier load must not make every later click pay the timeout.
|
|
457
|
+
if navigating
|
|
458
|
+
@navigation_event.wait(@options.timeout)
|
|
459
|
+
wait_for_document_ready
|
|
460
|
+
end
|
|
444
461
|
@default_context_event.wait(@options.timeout)
|
|
445
462
|
@turbo_event.wait(@options.timeout)
|
|
446
463
|
check_unhandled_modal!
|
|
@@ -607,6 +624,111 @@ module Capybara
|
|
|
607
624
|
page_command("Runtime.enable")
|
|
608
625
|
end
|
|
609
626
|
|
|
627
|
+
# Track cross-document navigations of the main frame, from the moment
|
|
628
|
+
# they start until the frame stops loading. Same-document ones
|
|
629
|
+
# (pushState, fragment) emit Page.navigatedWithinDocument instead and
|
|
630
|
+
# never reset the event, so they don't make wait_for_idle block; nor do
|
|
631
|
+
# iframe navigations (other frameId). The main frame shares the
|
|
632
|
+
# target's id.
|
|
633
|
+
#
|
|
634
|
+
# It ends at whichever comes first: the main frame's new default
|
|
635
|
+
# execution context (the new document committed — enough even when
|
|
636
|
+
# the load event never comes, lightpanda-io/browser#1801) or
|
|
637
|
+
# Page.frameStoppedLoading. A navigation whose request fails
|
|
638
|
+
# (connection refused, DNS) emits neither — only Network.loadingFailed for the
|
|
639
|
+
# document request — so that ends it too, keyed by the requestId the
|
|
640
|
+
# main-frame Document request was sent with. Needs Page.enable, which
|
|
641
|
+
# the first go_to turns on; a page that was never visited has no
|
|
642
|
+
# navigation to click into.
|
|
643
|
+
def subscribe_to_navigation
|
|
644
|
+
@navigation_event = Utils::Event.new
|
|
645
|
+
@navigation_event.set
|
|
646
|
+
@navigation_request_id = nil
|
|
647
|
+
|
|
648
|
+
on("Page.frameStartedNavigating") do |params|
|
|
649
|
+
@navigation_event.reset if main_frame_navigation_start?(params)
|
|
650
|
+
end
|
|
651
|
+
on("Network.requestWillBeSent") do |params|
|
|
652
|
+
@navigation_request_id = params["requestId"] if main_frame_document_request?(params)
|
|
653
|
+
end
|
|
654
|
+
on("Page.frameStoppedLoading") do |params|
|
|
655
|
+
@navigation_event.set if params["frameId"] == @target_id
|
|
656
|
+
end
|
|
657
|
+
on("Runtime.executionContextCreated") do |params|
|
|
658
|
+
aux = params.dig("context", "auxData") || {}
|
|
659
|
+
@navigation_event.set if aux["isDefault"] && aux["frameId"] == @target_id
|
|
660
|
+
end
|
|
661
|
+
on("Network.loadingFailed") do |params|
|
|
662
|
+
@navigation_event.set if params["requestId"] == @navigation_request_id
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# The new document's context exists before its deferred and module
|
|
667
|
+
# scripts have run: this event marks the end of that phase, at
|
|
668
|
+
# Page.domContentEventFired or anything later that proves it is over
|
|
669
|
+
# (load, the frame stopping, the document request failing).
|
|
670
|
+
def subscribe_to_document_ready
|
|
671
|
+
@document_ready_event = Utils::Event.new
|
|
672
|
+
@document_ready_event.set
|
|
673
|
+
|
|
674
|
+
on("Page.frameStartedNavigating") do |params|
|
|
675
|
+
@document_ready_event.reset if main_frame_navigation_start?(params)
|
|
676
|
+
end
|
|
677
|
+
on("Page.domContentEventFired") { @document_ready_event.set }
|
|
678
|
+
on("Page.loadEventFired") { @document_ready_event.set }
|
|
679
|
+
on("Page.frameStoppedLoading") do |params|
|
|
680
|
+
@document_ready_event.set if params["frameId"] == @target_id
|
|
681
|
+
end
|
|
682
|
+
on("Network.loadingFailed") do |params|
|
|
683
|
+
@document_ready_event.set if params["requestId"] == @navigation_request_id
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
# After a click that navigated, the next action must not run before the
|
|
688
|
+
# new page's JS has wired its handlers: Stimulus, Bootstrap's data-api
|
|
689
|
+
# and Turbo load as module scripts, which run after the document commits
|
|
690
|
+
# and before DOMContentLoaded. Cuprite waits for the load event. The
|
|
691
|
+
# event is awaited briefly, then document.readyState is polled for the
|
|
692
|
+
# rest of the budget, as #go_to does (lightpanda-io/browser#1801: page
|
|
693
|
+
# events may never come). Never raises: a page that never settles costs
|
|
694
|
+
# the timeout once, then the action returns as it did before.
|
|
695
|
+
DOCUMENT_READY_EVENT_WAIT = 2
|
|
696
|
+
private_constant :DOCUMENT_READY_EVENT_WAIT
|
|
697
|
+
|
|
698
|
+
def wait_for_document_ready
|
|
699
|
+
deadline = monotonic_time + @options.timeout
|
|
700
|
+
return if @document_ready_event.wait([DOCUMENT_READY_EVENT_WAIT, @options.timeout].min)
|
|
701
|
+
|
|
702
|
+
remaining = deadline - monotonic_time
|
|
703
|
+
return unless remaining.positive?
|
|
704
|
+
|
|
705
|
+
Utils::Wait.until(timeout: remaining, interval: 0.1) do
|
|
706
|
+
@document_ready_event.set? || @client.closed? || document_complete?
|
|
707
|
+
end
|
|
708
|
+
rescue TimeoutError
|
|
709
|
+
nil
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
def document_complete?
|
|
713
|
+
response = @client.command(
|
|
714
|
+
"Runtime.evaluate",
|
|
715
|
+
{ expression: "document.readyState", returnByValue: true },
|
|
716
|
+
session_id: @session_id,
|
|
717
|
+
timeout: 2
|
|
718
|
+
)
|
|
719
|
+
response.dig("result", "value") == "complete"
|
|
720
|
+
rescue Error
|
|
721
|
+
false
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
def main_frame_navigation_start?(params)
|
|
725
|
+
params["frameId"] == @target_id && !params["navigationType"].to_s.match?(/same/i)
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
def main_frame_document_request?(params)
|
|
729
|
+
params["type"] == "Document" && params["frameId"] == @target_id
|
|
730
|
+
end
|
|
731
|
+
|
|
610
732
|
def close_client_silently
|
|
611
733
|
@client&.close
|
|
612
734
|
rescue StandardError
|
|
@@ -83,7 +83,10 @@ module Capybara
|
|
|
83
83
|
# Lightpanda answers `{success: false}` and drops the cookie for an
|
|
84
84
|
# invalid combination — notably `same_site: "None"` without `secure`
|
|
85
85
|
# since upstream #3529 (build 9877), Chrome's rule — so callers that
|
|
86
|
-
# need it to stick should check the result.
|
|
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.
|
|
87
90
|
def set(name:, value:, domain: nil, path: "/", secure: false, http_only: false, # rubocop:disable Metrics/ParameterLists, Naming/PredicateMethod
|
|
88
91
|
same_site: nil, expires: nil)
|
|
89
92
|
params = {
|
|
@@ -126,8 +129,10 @@ module Capybara
|
|
|
126
129
|
# CDP requires either domain or url for each cookie; entries from `store`
|
|
127
130
|
# already include domain, so they round-trip cleanly. Returns true on
|
|
128
131
|
# success (intentionally not a predicate — mirrors ferrum's API). A
|
|
129
|
-
# cookie the browser refuses
|
|
130
|
-
#
|
|
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.
|
|
131
136
|
def load(path = "cookies.yml") # rubocop:disable Naming/PredicateMethod
|
|
132
137
|
cookies = YAML.load_file(path)
|
|
133
138
|
rejected = cookies.reject { |c| restore_cookie(c) }.map { |c| c.transform_keys(&:to_s)["name"] }
|
|
@@ -153,9 +158,22 @@ module Capybara
|
|
|
153
158
|
params[:same_site] = same_site if same_site
|
|
154
159
|
exp = attrs["expires"]
|
|
155
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)
|
|
156
165
|
set(**params)
|
|
166
|
+
rescue BrowserError => e
|
|
167
|
+
raise unless e.code == INVALID_PARAMS
|
|
168
|
+
|
|
169
|
+
false
|
|
157
170
|
end
|
|
158
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
|
+
|
|
159
177
|
# Lightpanda below build 9877 reported every cookie without a SameSite
|
|
160
178
|
# attribute as "None", so a file stored then is full of insecure
|
|
161
179
|
# SameSite=None cookies — which the browser now rejects (Chrome's rule,
|
|
@@ -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
|
|
@@ -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
|
}
|
|
@@ -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,12 +269,9 @@ 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
276
|
call(SEND_KEYS_FOCUS_JS)
|
|
276
277
|
driver.browser.keyboard.type(*)
|
|
@@ -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
|
|
@@ -629,22 +641,36 @@ module Capybara
|
|
|
629
641
|
var notCancelled = hit.dispatchEvent(clickEvt);
|
|
630
642
|
if (!notCancelled || clickEvt.defaultPrevented) return;
|
|
631
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;
|
|
632
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;
|
|
633
659
|
// Same-document fragment-only navigation: just update hash (or do
|
|
634
660
|
// nothing if identical). Mirrors Chrome — assigning location.href
|
|
635
661
|
// to a same-document URL on Lightpanda triggers a real navigation
|
|
636
662
|
// tick that cancels pending setTimeout callbacks and clears form
|
|
637
663
|
// values, which breaks any test driving DOM updates from a click
|
|
638
664
|
// handler on `<a href="#...">`.
|
|
639
|
-
var dest = new URL(hit.href,
|
|
640
|
-
var here = new URL(
|
|
665
|
+
var dest = new URL(hit.href, hit.ownerDocument.baseURI);
|
|
666
|
+
var here = new URL(win.location.href);
|
|
641
667
|
if (dest.origin === here.origin && dest.pathname === here.pathname &&
|
|
642
668
|
dest.search === here.search) {
|
|
643
669
|
if (dest.hash !== here.hash) {
|
|
644
|
-
|
|
670
|
+
win.location.hash = dest.hash;
|
|
645
671
|
}
|
|
646
672
|
} else {
|
|
647
|
-
|
|
673
|
+
win.location.href = hit.href;
|
|
648
674
|
}
|
|
649
675
|
}
|
|
650
676
|
}
|