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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 759abeb96b5d6898d9458d2e81af39b97d155766cfd2c80fa1ed363409a6aff6
|
|
4
|
+
data.tar.gz: 66d54d836501ab8ec7d3cc23ffc389f58777c938a19490f9dc81a076819b4489
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ffdc46e2b18c6ce822fb7765cc668bfda6594e1ce27f9ad88b29e4d9a34f6882b80aad54fcb0581d460e68b3fbcaaaa853017f825295b1a8758a244da520d12d
|
|
7
|
+
data.tar.gz: 6ae51661f2a7108ec85bebf773965dd792f74d04b80cdff7c5992b41be5c32f15b3d7e08d97a64212a64a812be4ebe811f222ad6fa91866888d1f6e50a805b71
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.14.0] - 2026-10-11
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **`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.
|
|
8
|
+
- **`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.
|
|
9
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
- **`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.
|
|
12
|
+
- **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.
|
|
13
|
+
- **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.
|
|
14
|
+
- **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.
|
|
15
|
+
- **`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.
|
|
16
|
+
|
|
17
|
+
## [0.13.0] - 2026-10-03
|
|
18
|
+
|
|
19
|
+
> **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.
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- **Cross-origin requests follow CORS, as in Chrome.** Lightpanda 1.0.0 enforces CORS by default, and the driver doesn't turn it off, so a `fetch` or XHR your app sends from `127.0.0.1` to `localhost` (or to another port) is blocked there just as Chrome would block it. Suites already green on Cuprite or Selenium are unaffected. A blocked request is listed in `network.traffic` with an `:error` and no response, and it no longer stalls `wait_for_network_idle` (see Fixed).
|
|
24
|
+
- **`Cookies#set` returns whether the browser accepted the cookie.** Lightpanda now refuses `SameSite=None` without `Secure`. `set` returns `false` in that case instead of dropping the cookie silently. `Cookies#load` repairs cookie files saved by older versions, which marked every cookie `SameSite=None`, and warns about any cookie it still can't restore.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **Clicking a field focuses it again.** On current Lightpanda builds, `click` followed by `send_keys` sent the keys to the page body instead of the clicked field. Clicking a field, a label or a contenteditable region now focuses it, and clicking empty space blurs the focused element, as in Chrome. Capybara's `active_element` specs now pass.
|
|
29
|
+
- **`send_keys` appends to a field's existing value.** On a field that was pre-filled and not yet focused, `send_keys("x")` produced `"xfoo"` instead of `"foox"`. Typing now starts at the end of the value, as in chromedriver. If you need to type at a specific position, focus the field and place the caret yourself first.
|
|
30
|
+
- **Backspace, Delete, Tab, Escape and the arrow keys reach the page.** Previously only keys that type a character were delivered, so these keys did nothing. They now edit fields, move the caret and extend the selection with Shift. A `beforeinput` handler can cancel an edit, which input-mask libraries rely on.
|
|
31
|
+
- **Ctrl/Cmd shortcuts no longer type a character.** `send_keys([:ctrl, "a"])` used to insert a stray `a`. Your shortcut handlers still receive the key.
|
|
32
|
+
- **`select` never picks a disabled option, and picks the right one when values repeat.** When several options shared the same value, the first one was always selected.
|
|
33
|
+
- **Failed requests no longer stall `wait_for_network_idle`.** A request that failed without a response (blocked by CORS, refused connection) stayed pending until the timeout expired.
|
|
34
|
+
- **`console_logs` shows objects.** `console.log({a: 1})`, arrays and DOM elements used to appear as empty text. They now appear as a short description.
|
|
35
|
+
- **A browser crash no longer takes your test process down with it.** If the connection died unexpectedly, the error could be raised somewhere unrelated in your test, or abort a server before its cleanup ran. Commands now fail cleanly with `DeadBrowserError`, waiting commands are released immediately, and each crashed browser releases its connection instead of keeping it open until garbage collection.
|
|
36
|
+
- **`matchMedia` listeners fire when you resize the window.** `resize_window_to` now runs the JavaScript breakpoint handlers. Stylesheet `@media` rules still only apply after the next `visit`.
|
|
37
|
+
|
|
3
38
|
## [0.12.0] - 2026-08-28
|
|
4
39
|
|
|
5
40
|
> **Update Lightpanda before upgrading.** This release requires a Lightpanda nightly build ≥ 8875, and the rolling `nightly` tag now carries it (8982 as of 2026-08-28), so updating the browser is all it takes. **No tagged Lightpanda release is new enough yet**: 0.3.7 is below the floor, so version pinning stays unavailable until Lightpanda ships its next release.
|
data/README.md
CHANGED
|
@@ -57,7 +57,7 @@ driven_by :lightpanda
|
|
|
57
57
|
> Lightpanda is a headless agentic browser, not a layout engine. External `<link rel="stylesheet">` **are** fetched and applied (the gem enables this by default), and `@media` / `window.matchMedia()` evaluate against the `window_size` you configure — so a mobile-only CTA gated by `@media (max-width: …)` resolves at the width you ask for. What's missing is *layout*: nothing reflows, `getBoundingClientRect` stays synthetic, and there is no real scroll. Specs that assert on pixel geometry, scrolling, or screenshots — plus the two that catch people out, a second browser tab and a menu revealed purely by CSS `:hover` — should stay on Cuprite (or whichever full-browser driver you were already using). The [per-spec dual-driver setup](https://navidemad.github.io/capybara-lightpanda/docs/#dual-per-spec) routes that minority to Cuprite and the structural majority to Lightpanda for speed.
|
|
58
58
|
|
|
59
59
|
> [!TIP]
|
|
60
|
-
> For reproducible CI, pin the browser: `Capybara::Lightpanda::Binary.required_version = "0.
|
|
60
|
+
> For reproducible CI, pin the browser: `Capybara::Lightpanda::Binary.required_version = "1.0.0"` (the tagged release the gem's current floor is pinned to — nightly build 9994 is that release's commit). Without a pin the driver tracks Lightpanda's rolling `nightly` tag, which moves under you. See [Pinning the browser version](https://navidemad.github.io/capybara-lightpanda/docs/#pinning).
|
|
61
61
|
|
|
62
62
|
## Credits
|
|
63
63
|
|
|
@@ -59,14 +59,27 @@ module Capybara
|
|
|
59
59
|
|
|
60
60
|
on("Runtime.consoleAPICalled") do |params|
|
|
61
61
|
params["args"]&.each do |r|
|
|
62
|
-
|
|
63
|
-
next if driver_sentinel?(value)
|
|
62
|
+
next if driver_sentinel?(r["value"])
|
|
64
63
|
|
|
65
|
-
logger.puts(
|
|
64
|
+
logger.puts(console_arg_text(r))
|
|
66
65
|
end
|
|
67
66
|
end
|
|
68
67
|
end
|
|
69
68
|
|
|
69
|
+
# One console argument as text. Since upstream #3504 (build 9414)
|
|
70
|
+
# Lightpanda sends `value` for primitives only — an object, array or
|
|
71
|
+
# DOM node arrives as a RemoteObject with a `description` (sometimes
|
|
72
|
+
# empty), a `className` and a `type`. Reading `value` alone printed
|
|
73
|
+
# objects as blank lines in the logger and DOM nodes as "" in
|
|
74
|
+
# console_logs; fall through to the first non-empty descriptor,
|
|
75
|
+
# the way DevTools labels an object it can't preview.
|
|
76
|
+
def console_arg_text(arg)
|
|
77
|
+
return arg["value"].to_s if arg.key?("value")
|
|
78
|
+
|
|
79
|
+
[arg["unserializableValue"], arg["description"], arg["className"], arg["type"]]
|
|
80
|
+
.find { |v| v.is_a?(String) && !v.empty? }.to_s
|
|
81
|
+
end
|
|
82
|
+
|
|
70
83
|
TURBO_SENTINEL_PREFIX = "__lightpanda_turbo_"
|
|
71
84
|
private_constant :TURBO_SENTINEL_PREFIX
|
|
72
85
|
|
|
@@ -117,7 +130,7 @@ module Capybara
|
|
|
117
130
|
|
|
118
131
|
entry = {
|
|
119
132
|
type: params["type"],
|
|
120
|
-
text: args.map { |a| a
|
|
133
|
+
text: args.map { |a| console_arg_text(a) }.join(" "),
|
|
121
134
|
timestamp: params["timestamp"],
|
|
122
135
|
args: args,
|
|
123
136
|
}
|
|
@@ -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,7 @@ module Capybara
|
|
|
166
166
|
subscribe_to_console_logs
|
|
167
167
|
subscribe_to_console_capture
|
|
168
168
|
subscribe_to_execution_context
|
|
169
|
+
subscribe_to_navigation
|
|
169
170
|
subscribe_to_turbo_signals
|
|
170
171
|
# Network owns the Network.* domain: enabling installs traffic
|
|
171
172
|
# tracking AND the navigation-response capture behind status_code.
|
|
@@ -423,17 +424,25 @@ module Capybara
|
|
|
423
424
|
# (title, current_url, …) without racing the navigation lifecycle.
|
|
424
425
|
#
|
|
425
426
|
# Sniff window: the action returns synchronously, but the CDP events
|
|
426
|
-
# signalling its async fallout
|
|
427
|
-
#
|
|
428
|
-
#
|
|
429
|
-
#
|
|
427
|
+
# signalling its async fallout arrive later on the dispatch thread. We
|
|
428
|
+
# poll briefly for any of them — a main-frame navigation start (see
|
|
429
|
+
# subscribe_to_navigation), Runtime.executionContextsCleared, or the
|
|
430
|
+
# turbo sentinel — and if none fires within the window, assume the
|
|
431
|
+
# action was inert and exit fast. Lightpanda announces a navigation
|
|
432
|
+
# ~5 ms after the click but only swaps the execution context once the
|
|
433
|
+
# response lands, so watching the context alone returned on the old
|
|
434
|
+
# document whenever the server took longer than the window.
|
|
430
435
|
SNIFF_WINDOW = 0.05
|
|
431
436
|
private_constant :SNIFF_WINDOW
|
|
432
437
|
|
|
433
438
|
def wait_for_idle
|
|
434
439
|
prior_context_iteration = @default_context_event.iteration
|
|
440
|
+
prior_navigation_iteration = @navigation_event.iteration
|
|
435
441
|
sniff_deadline = monotonic_time + SNIFF_WINDOW
|
|
442
|
+
navigating = false
|
|
436
443
|
loop do
|
|
444
|
+
navigating = @navigation_event.iteration > prior_navigation_iteration
|
|
445
|
+
break if navigating
|
|
437
446
|
break if @default_context_event.iteration > prior_context_iteration
|
|
438
447
|
break unless @turbo_event.set?
|
|
439
448
|
break if monotonic_time > sniff_deadline
|
|
@@ -441,6 +450,9 @@ module Capybara
|
|
|
441
450
|
sleep 0.001
|
|
442
451
|
end
|
|
443
452
|
|
|
453
|
+
# Only a navigation this action started is awaited: one left pending
|
|
454
|
+
# by an earlier load must not make every later click pay the timeout.
|
|
455
|
+
@navigation_event.wait(@options.timeout) if navigating
|
|
444
456
|
@default_context_event.wait(@options.timeout)
|
|
445
457
|
@turbo_event.wait(@options.timeout)
|
|
446
458
|
check_unhandled_modal!
|
|
@@ -607,6 +619,53 @@ module Capybara
|
|
|
607
619
|
page_command("Runtime.enable")
|
|
608
620
|
end
|
|
609
621
|
|
|
622
|
+
# Track cross-document navigations of the main frame, from the moment
|
|
623
|
+
# they start until the frame stops loading. Same-document ones
|
|
624
|
+
# (pushState, fragment) emit Page.navigatedWithinDocument instead and
|
|
625
|
+
# never reset the event, so they don't make wait_for_idle block; nor do
|
|
626
|
+
# iframe navigations (other frameId). The main frame shares the
|
|
627
|
+
# target's id.
|
|
628
|
+
#
|
|
629
|
+
# It ends at whichever comes first: the main frame's new default
|
|
630
|
+
# execution context (the new document committed — enough even when
|
|
631
|
+
# the load event never comes, lightpanda-io/browser#1801) or
|
|
632
|
+
# Page.frameStoppedLoading. A navigation whose request fails
|
|
633
|
+
# (connection refused, DNS) emits neither — only Network.loadingFailed for the
|
|
634
|
+
# document request — so that ends it too, keyed by the requestId the
|
|
635
|
+
# main-frame Document request was sent with. Needs Page.enable, which
|
|
636
|
+
# the first go_to turns on; a page that was never visited has no
|
|
637
|
+
# navigation to click into.
|
|
638
|
+
def subscribe_to_navigation
|
|
639
|
+
@navigation_event = Utils::Event.new
|
|
640
|
+
@navigation_event.set
|
|
641
|
+
@navigation_request_id = nil
|
|
642
|
+
|
|
643
|
+
on("Page.frameStartedNavigating") do |params|
|
|
644
|
+
@navigation_event.reset if main_frame_navigation_start?(params)
|
|
645
|
+
end
|
|
646
|
+
on("Network.requestWillBeSent") do |params|
|
|
647
|
+
@navigation_request_id = params["requestId"] if main_frame_document_request?(params)
|
|
648
|
+
end
|
|
649
|
+
on("Page.frameStoppedLoading") do |params|
|
|
650
|
+
@navigation_event.set if params["frameId"] == @target_id
|
|
651
|
+
end
|
|
652
|
+
on("Runtime.executionContextCreated") do |params|
|
|
653
|
+
aux = params.dig("context", "auxData") || {}
|
|
654
|
+
@navigation_event.set if aux["isDefault"] && aux["frameId"] == @target_id
|
|
655
|
+
end
|
|
656
|
+
on("Network.loadingFailed") do |params|
|
|
657
|
+
@navigation_event.set if params["requestId"] == @navigation_request_id
|
|
658
|
+
end
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
def main_frame_navigation_start?(params)
|
|
662
|
+
params["frameId"] == @target_id && !params["navigationType"].to_s.match?(/same/i)
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
def main_frame_document_request?(params)
|
|
666
|
+
params["type"] == "Document" && params["frameId"] == @target_id
|
|
667
|
+
end
|
|
668
|
+
|
|
610
669
|
def close_client_silently
|
|
611
670
|
@client&.close
|
|
612
671
|
rescue StandardError
|
|
@@ -33,14 +33,19 @@ module Capybara
|
|
|
33
33
|
@driver_mutex.synchronize { @driver.text(message) }
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
# The socket is closed even when the connection is already dead. A
|
|
37
|
+
# browser that dies first makes the reader thread #mark_dead the
|
|
38
|
+
# connection (status :closed or :error) without closing the socket,
|
|
39
|
+
# and an early return here then left the TCPSocket — one descriptor
|
|
40
|
+
# per crashed browser — open until GC finalized it (ferrum #639).
|
|
36
41
|
def close
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
unless @status == :closed
|
|
43
|
+
@status = :closing
|
|
44
|
+
@messages.close
|
|
45
|
+
@driver_mutex.synchronize { @driver&.close }
|
|
46
|
+
@thread&.join(1) || @thread&.kill
|
|
47
|
+
end
|
|
48
|
+
close_socket
|
|
44
49
|
@status = :closed
|
|
45
50
|
end
|
|
46
51
|
|
|
@@ -54,8 +59,24 @@ module Capybara
|
|
|
54
59
|
mark_dead
|
|
55
60
|
end
|
|
56
61
|
|
|
62
|
+
# Single home for the "dead implies queue closed" invariant: every
|
|
63
|
+
# path that gives up on the connection must close @messages so the
|
|
64
|
+
# Client message thread's blocking pop returns. Public because the
|
|
65
|
+
# Client calls it when its own message thread dies — either transport
|
|
66
|
+
# thread dying means the connection is dead.
|
|
67
|
+
def mark_dead(status = :closed)
|
|
68
|
+
@status = status
|
|
69
|
+
@messages.close
|
|
70
|
+
end
|
|
71
|
+
|
|
57
72
|
private
|
|
58
73
|
|
|
74
|
+
def close_socket
|
|
75
|
+
@socket&.close unless @socket.nil? || @socket.closed?
|
|
76
|
+
rescue IOError, SystemCallError
|
|
77
|
+
# Already torn down by the peer; nothing left to release.
|
|
78
|
+
end
|
|
79
|
+
|
|
59
80
|
def connect
|
|
60
81
|
uri = URI.parse(@url)
|
|
61
82
|
|
|
@@ -95,19 +116,25 @@ module Capybara
|
|
|
95
116
|
|
|
96
117
|
@driver.on(:error) do |event|
|
|
97
118
|
# Do NOT raise here. This callback fires synchronously from
|
|
98
|
-
# @driver.parse(data) inside the reader thread
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
# process. Mark the connection dead and let Client#command
|
|
102
|
-
# surface DeadBrowserError on its next dispatch via closed?.
|
|
119
|
+
# @driver.parse(data) inside the reader thread. Mark the
|
|
120
|
+
# connection dead and let Client#command surface DeadBrowserError
|
|
121
|
+
# on its next dispatch via closed?.
|
|
103
122
|
@logger&.puts("✗ WebSocket error: #{event.message}")
|
|
104
123
|
mark_dead(:error)
|
|
105
124
|
end
|
|
106
125
|
end
|
|
107
126
|
|
|
127
|
+
# The reader must never take the host process down. With
|
|
128
|
+
# abort_on_exception an error the narrow IO rescue doesn't expect
|
|
129
|
+
# (Errno::ETIMEDOUT is a SystemCallError, not an IOError) was re-raised
|
|
130
|
+
# on the main thread wherever it happened to be: a spec failure blamed
|
|
131
|
+
# on an unrelated call, or a server dying with its `ensure` never
|
|
132
|
+
# reaching browser.quit. Any error here means the connection is dead —
|
|
133
|
+
# report it and let the next command raise DeadBrowserError
|
|
134
|
+
# (mirrors ferrum #632).
|
|
108
135
|
def start_reader_thread
|
|
109
136
|
@thread = Thread.new do
|
|
110
|
-
Thread.current.abort_on_exception =
|
|
137
|
+
Thread.current.abort_on_exception = false
|
|
111
138
|
|
|
112
139
|
loop do
|
|
113
140
|
break if @status == :closed || @status == :closing
|
|
@@ -120,6 +147,10 @@ module Capybara
|
|
|
120
147
|
rescue Errno::ECONNRESET, Errno::EPIPE, IOError
|
|
121
148
|
mark_dead
|
|
122
149
|
break
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
warn "Capybara::Lightpanda: WebSocket reader died: #{e.class}: #{e.message}"
|
|
152
|
+
mark_dead(:error)
|
|
153
|
+
break
|
|
123
154
|
end
|
|
124
155
|
end
|
|
125
156
|
end
|
|
@@ -144,14 +175,6 @@ module Capybara
|
|
|
144
175
|
raise TimeoutError, "WebSocket handshake timed out after #{@options.handshake_timeout}s"
|
|
145
176
|
end
|
|
146
177
|
|
|
147
|
-
# Single home for the "dead implies queue closed" invariant: every
|
|
148
|
-
# path that gives up on the connection must close @messages so the
|
|
149
|
-
# Client message thread's blocking pop returns.
|
|
150
|
-
def mark_dead(status = :closed)
|
|
151
|
-
@status = status
|
|
152
|
-
@messages.close
|
|
153
|
-
end
|
|
154
|
-
|
|
155
178
|
def parse_message(data)
|
|
156
179
|
JSON.parse(data, max_nesting: false)
|
|
157
180
|
rescue JSON::ParserError => e
|
|
@@ -102,12 +102,31 @@ module Capybara
|
|
|
102
102
|
@pendings.each_value { |ivar| ivar.try_set(nil) }
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
+
# The message thread must never take the host process down: with
|
|
106
|
+
# abort_on_exception, anything handle_message raised was re-raised on
|
|
107
|
+
# the main thread mid-whatever-it-was-doing. An unexpected error here
|
|
108
|
+
# means the connection is dead — mark it so and stop (ferrum #632).
|
|
109
|
+
#
|
|
110
|
+
# The `ensure` is also the fail-fast for a connection that dies
|
|
111
|
+
# *underneath* us: the WebSocket's mark_dead closes @messages, pop
|
|
112
|
+
# returns nil, the loop ends, and every blocked caller is released to
|
|
113
|
+
# raise DeadBrowserError instead of waiting out its full timeout
|
|
114
|
+
# (ferrum #630). Ordering is what makes that work — mark_dead flips the
|
|
115
|
+
# status before it closes the queue, so by the time the waiters wake,
|
|
116
|
+
# `@ws.closed?` is already true.
|
|
105
117
|
def start_message_thread
|
|
106
118
|
@message_thread = Thread.new do
|
|
107
|
-
Thread.current.abort_on_exception =
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
119
|
+
Thread.current.abort_on_exception = false
|
|
120
|
+
|
|
121
|
+
begin
|
|
122
|
+
while (message = @ws.messages.pop)
|
|
123
|
+
handle_message(message)
|
|
124
|
+
end
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
warn "Capybara::Lightpanda: CDP message thread died: #{e.class}: #{e.message}"
|
|
127
|
+
@ws.mark_dead(:error)
|
|
128
|
+
ensure
|
|
129
|
+
fail_pending_commands
|
|
111
130
|
end
|
|
112
131
|
end
|
|
113
132
|
end
|
|
@@ -117,8 +136,8 @@ module Capybara
|
|
|
117
136
|
pending = @pendings[message["id"]]
|
|
118
137
|
# try_set, not set: a duplicate frame for an already-answered id
|
|
119
138
|
# (Lightpanda emits occasional malformed/duplicate frames — see
|
|
120
|
-
# upstream-wishlist.md A41) would raise MultipleAssignmentError
|
|
121
|
-
#
|
|
139
|
+
# upstream-wishlist.md A41) would raise MultipleAssignmentError and
|
|
140
|
+
# kill the connection for a frame we should simply ignore.
|
|
122
141
|
pending&.try_set(message)
|
|
123
142
|
elsif message["method"]
|
|
124
143
|
@subscriber.dispatch(message["method"], message["params"])
|