capybara-lightpanda 0.14.2 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +4 -4
- data/lib/capybara/lightpanda/auto_scripts.rb +3 -3
- data/lib/capybara/lightpanda/browser/console.rb +73 -51
- data/lib/capybara/lightpanda/browser/finder.rb +83 -32
- data/lib/capybara/lightpanda/browser/modals.rb +8 -7
- data/lib/capybara/lightpanda/browser/navigation.rb +10 -1
- data/lib/capybara/lightpanda/browser/runtime.rb +3 -3
- data/lib/capybara/lightpanda/browser.rb +13 -4
- data/lib/capybara/lightpanda/client/web_socket.rb +21 -2
- data/lib/capybara/lightpanda/client.rb +4 -1
- data/lib/capybara/lightpanda/cookies.rb +1 -1
- data/lib/capybara/lightpanda/downloads.rb +4 -4
- data/lib/capybara/lightpanda/driver.rb +27 -28
- data/lib/capybara/lightpanda/errors.rb +3 -3
- data/lib/capybara/lightpanda/javascripts/attach.js +2 -1
- data/lib/capybara/lightpanda/javascripts/predicates.js +81 -9
- data/lib/capybara/lightpanda/keyboard.rb +15 -5
- data/lib/capybara/lightpanda/network.rb +1 -1
- data/lib/capybara/lightpanda/node.rb +114 -71
- data/lib/capybara/lightpanda/options.rb +9 -7
- data/lib/capybara/lightpanda/process.rb +71 -6
- data/lib/capybara/lightpanda/version.rb +1 -1
- metadata +1 -2
- data/lib/capybara/lightpanda/javascripts/errors.js +0 -74
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a8f6c1cfdad13de4569b3d79828d9d19848b166e220b6a91329c7c0cb2fa9781
|
|
4
|
+
data.tar.gz: 2f7c90bb35b746682f5533c9d7ebfe1b64fbf7aea1f53a6c93f563428d9f3b46
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1d1632dc29185af1e1d1c594abd7a9fe4b0af584585aa6c6534428d59b23316174db581d901bd7d67430b2e708c664c9c7175918cfeca4d386ba95453816b910
|
|
7
|
+
data.tar.gz: 76a79eed2e960c294a09c1ee8882014d543c5695cd519d51c3d9560ca7b153b085ae4161f18e2a11a301465b95c0cc71140a269c3c8970c86e93d291430630cb
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.16.0] - 2026-10-26
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **`trigger(:click)` waits for the page it loads.** Calling `element.trigger(:click)` on a link or submit button returned before the new page arrived, so the next step ran against the old page. It now waits the way `click` does.
|
|
8
|
+
- **Clicking a `<a download>` link no longer cancels the download on recent nightlies.** From nightly 11097 (and the upcoming 1.1 release line), Lightpanda starts the download itself when a link has a `download` attribute. The driver then also went to the same URL, which cancelled the download and replaced the page. The driver now leaves the download to the browser. Older browsers behave as before.
|
|
9
|
+
- **`obscured?` sees overlays inside a shadow root.** On browsers that can hit-test inside shadow trees (recent nightlies), an element covered by another element in the same shadow root now counts as obscured; before, it was always reported as not obscured. Older browsers give the same answer as before.
|
|
10
|
+
|
|
11
|
+
## [0.15.0] - 2026-10-23
|
|
12
|
+
|
|
13
|
+
> **Update Lightpanda before upgrading.** This release requires Lightpanda **1.0.2** or a nightly build ≥ 10965. The driver refuses anything older and names the version it found. Pin `1.0.2` for a reproducible browser.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **The driver keeps Lightpanda's recent output.** `browser.process.output` returns the browser's last 100 lines of output. When the browser crashes during a navigation, the `DeadBrowserError` message quotes the last 20 of those lines, so the crash is no longer silent.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **`page_errors` now reads the browser's own exception reports.** It used to rely on a script the driver injected into every page to listen for errors. It now reads `Runtime.exceptionThrown`, the event Chrome clients use, which Lightpanda emits from 1.0.2. The entries look the same as before. Two differences: an error that the page cancels itself (an `error` listener calling `preventDefault()`) is no longer listed, as in Chrome; and the driver no longer adds a listener script to your pages.
|
|
22
|
+
- **Finding elements by text is much faster.** `find("li", text: "…")` used to ask the browser about every candidate one by one. Visibility and text are now checked inside the find itself: on a list of 300 items, that's 4 round-trips instead of about 500.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **`page_errors` no longer lists rejections the page handled.** On Lightpanda 1.0.1, a promise rejected and caught in the same tick (`asyncFn().catch(handler)`, `Promise.reject(e).catch(handler)`) still showed up in `page_errors` as an unhandled rejection. The new browser floor fixes this.
|
|
27
|
+
- **`find` and `click` work before the first `visit`.** On a fresh session, or right after `reset_session!`, finding an element built with `execute_script` raised `ReferenceError: _lightpanda is not defined`.
|
|
28
|
+
- **Delegated `change` and `submit` handlers fire again under jQuery 1.x.** jQuery 1.x checks at load time whether the browser supports these events on `window`. Lightpanda used to fail that check, so jQuery switched to its old Internet Explorer workaround and no delegated `change` or `submit` handler ever ran (Solidus admin, Backbone-era apps). The new browser floor fixes this; no code change required on your end.
|
|
29
|
+
- **`send_keys` types numbers, as Selenium does.** `send_keys [1, :tab]` used to drop the `1` silently and send only the Tab.
|
|
30
|
+
- **An HTML5 drag ends with `drop` or `dragleave`, never both.** `drag_to` fired `dragleave` and then `drop` on a target that accepted the drag. Libraries such as Pragmatic drag and drop read that `dragleave` as the drag leaving the window and dropped onto nothing.
|
|
31
|
+
- **`drag_to` keeps every `dragover` inside the viewport on recent nightlies.** From nightly 10661, the target could end up above the window, so the first `dragover` reported a negative `clientY`, which sortable lists and dropzones that hit-test on coordinates misread. The driver now centers the target itself, as Chrome does.
|
|
32
|
+
- **Elements inside a shadow root are no longer reported as obscured.** `obscured?` reported every shadow DOM element as covered by its own host.
|
|
33
|
+
- **`trigger(:input)` sends a real `InputEvent`.** Listeners that read `event.inputType` got `undefined`; they now get `"insertText"`, as when typing.
|
|
34
|
+
- **`evaluate_script` no longer times out on a string that ends in half an emoji.** The result was dropped and the call waited for the full timeout. The broken character now comes back as `�`.
|
|
35
|
+
- **Passing an element from a previous page to `execute_script` lets Capybara find it again.** It used to raise a plain `BrowserError`; Capybara now treats the element as obsolete and finds it again in the new page.
|
|
36
|
+
|
|
3
37
|
## [0.14.2] - 2026-10-14
|
|
4
38
|
|
|
5
39
|
### Fixed
|
data/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Self-contained — built-in CDP client, no external browser-client gem required.
|
|
|
14
14
|
<strong>Capybara</strong> → <code>capybara-lightpanda</code> → <a href="https://lightpanda.io/"><img src="docs/static/img/lightpanda-logo.svg" alt="Lightpanda" height="22" valign="middle"></a> <a href="https://github.com/lightpanda-io/browser/stargazers"><img src="https://img.shields.io/github/stars/lightpanda-io/browser?logo=github&label=stars" alt="GitHub stars" valign="middle"></a>
|
|
15
15
|
|
|
16
16
|
[](https://navidemad.github.io/capybara-lightpanda/)
|
|
17
|
-
<sub><em>Configuration ·
|
|
17
|
+
<sub><em>Configuration · setup recipes · Turbo Rails · known limitations · beta-testing guide</em></sub>
|
|
18
18
|
|
|
19
19
|
[](https://navidemad.github.io/capybara-lightpanda/)
|
|
20
20
|
|
|
@@ -54,16 +54,16 @@ driven_by :lightpanda
|
|
|
54
54
|
> The Lightpanda binary is auto-downloaded on first use — no separate install step needed.
|
|
55
55
|
|
|
56
56
|
> [!IMPORTANT]
|
|
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
|
|
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 can't pass here, nor can the two that catch people out: a second browser tab and a menu revealed purely by CSS `:hover`. See [known limitations](https://navidemad.github.io/capybara-lightpanda/docs/#limits).
|
|
58
58
|
|
|
59
59
|
> [!TIP]
|
|
60
|
-
> For reproducible CI, pin the browser: `Capybara::Lightpanda::Binary.required_version = "1.0.
|
|
60
|
+
> For reproducible CI, pin the browser: `Capybara::Lightpanda::Binary.required_version = "1.0.2"` (the tagged release the gem's current floor is pinned to — nightly build 10965 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
|
|
|
64
64
|
- [Lightpanda](https://lightpanda.io/) — the headless browser
|
|
65
65
|
- [Capybara](https://github.com/teamcapybara/capybara) — the test framework
|
|
66
|
-
- Inspired by the [
|
|
66
|
+
- Inspired by the [Ferrum](https://github.com/rubycdp/ferrum) architecture and [`lightpanda-ruby`](https://github.com/marcoroth/lightpanda-ruby)
|
|
67
67
|
|
|
68
68
|
Patterns adapted from these MIT-licensed projects (cookies API, frame switching, node call/error conventions, retry/event utilities) are acknowledged with the original copyright notices in [NOTICE.md](NOTICE.md).
|
|
69
69
|
|
|
@@ -17,9 +17,9 @@ module Capybara
|
|
|
17
17
|
|
|
18
18
|
# Order matters: declarations (turbo, predicates) before the wiring
|
|
19
19
|
# (attach) that reads their names. banner is a leading comment block.
|
|
20
|
-
# turbo.js
|
|
21
|
-
#
|
|
22
|
-
PARTS = %w[banner.js turbo.js
|
|
20
|
+
# turbo.js registers its listeners at top level and exposes nothing
|
|
21
|
+
# through attach.js, so it only needs to run inside the IIFE.
|
|
22
|
+
PARTS = %w[banner.js turbo.js predicates.js attach.js].freeze
|
|
23
23
|
|
|
24
24
|
# The guard short-circuits a repeat run before turbo.js can register its
|
|
25
25
|
# listeners a second time (double-registration would double-count
|
|
@@ -35,14 +35,13 @@ module Capybara
|
|
|
35
35
|
# them would also start failing every suite that already asserts
|
|
36
36
|
# console_logs holds no errors.
|
|
37
37
|
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
# try/catch) never
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
# failure take a hand-injected listener to explain.
|
|
38
|
+
# The source is Runtime.exceptionThrown, as in Chrome: Lightpanda emits
|
|
39
|
+
# it for an uncaught exception (#3905, build 10450) and an unhandled
|
|
40
|
+
# rejection (#4096, 10904), in iframes too (#4115, 10910). What it
|
|
41
|
+
# reports is what reaches `window` uncancelled: an exception a framework
|
|
42
|
+
# catches itself (Stimulus's handleError, any try/catch) never arrives,
|
|
43
|
+
# nor does one a page `error` listener cancels with preventDefault. The
|
|
44
|
+
# driver's own scripts raise JavaScriptError instead and never land here.
|
|
46
45
|
def page_errors
|
|
47
46
|
@page_errors_mutex.synchronize { @page_errors.dup }
|
|
48
47
|
end
|
|
@@ -51,6 +50,63 @@ module Capybara
|
|
|
51
50
|
@page_errors_mutex.synchronize { @page_errors.clear }
|
|
52
51
|
end
|
|
53
52
|
|
|
53
|
+
REJECTION_TEXT = "Uncaught (in promise)"
|
|
54
|
+
private_constant :REJECTION_TEXT
|
|
55
|
+
|
|
56
|
+
# Cap the stack: a deep recursion stack can be megabytes, and the buffer
|
|
57
|
+
# keeps up to PAGE_ERRORS_LIMIT of them.
|
|
58
|
+
STACK_LIMIT = 4_000
|
|
59
|
+
private_constant :STACK_LIMIT
|
|
60
|
+
|
|
61
|
+
# One Runtime.exceptionThrown event as a page_errors entry. The shape is
|
|
62
|
+
# the one javascripts/errors.js produced from the DOM `error` /
|
|
63
|
+
# `unhandledrejection` events before this event replaced it, so suites
|
|
64
|
+
# written against it keep working:
|
|
65
|
+
# - message: an Error's "Name: message" (the description up to its
|
|
66
|
+
# first stack frame), or a thrown primitive's value. A rejection's
|
|
67
|
+
# message drops the "Name: " prefix, as `reason.message` did.
|
|
68
|
+
# - line/column: 1-based like ErrorEvent's lineno/colno (CDP counts
|
|
69
|
+
# from 0), and nil for a rejection, which carries no location.
|
|
70
|
+
# - stack: the Error's description, which is its stack, or nil for a
|
|
71
|
+
# thrown primitive. Lightpanda sends no structured stackTrace.
|
|
72
|
+
def self.page_error_entry(params)
|
|
73
|
+
details = params["exceptionDetails"] || {}
|
|
74
|
+
exception = details["exception"] || {}
|
|
75
|
+
rejection = details["text"] == REJECTION_TEXT
|
|
76
|
+
stack = exception["subtype"] == "error" ? exception["description"].to_s : nil
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
kind: rejection ? "unhandledrejection" : "error",
|
|
80
|
+
message: page_error_message(exception, stack, rejection),
|
|
81
|
+
url: details["url"].to_s,
|
|
82
|
+
line: rejection ? nil : one_based(details["lineNumber"]),
|
|
83
|
+
column: rejection ? nil : one_based(details["columnNumber"]),
|
|
84
|
+
stack: stack&.slice(0, STACK_LIMIT),
|
|
85
|
+
timestamp: params["timestamp"],
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.one_based(position)
|
|
90
|
+
position + 1 if position
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.page_error_message(exception, stack, rejection)
|
|
94
|
+
return primitive_text(exception) unless stack
|
|
95
|
+
|
|
96
|
+
summary = stack.split(/\n\s+at /, 2).first
|
|
97
|
+
prefix = "#{exception['className']}: "
|
|
98
|
+
rejection && summary.start_with?(prefix) ? summary.delete_prefix(prefix) : summary
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# What String(value) gave for a thrown or rejected non-Error.
|
|
102
|
+
def self.primitive_text(exception)
|
|
103
|
+
return exception["value"].nil? ? "null" : exception["value"].to_s if exception.key?("value")
|
|
104
|
+
|
|
105
|
+
[exception["unserializableValue"], exception["description"], exception["type"]]
|
|
106
|
+
.find { |v| v.is_a?(String) && !v.empty? }.to_s
|
|
107
|
+
end
|
|
108
|
+
private_class_method :page_error_message, :one_based, :primitive_text
|
|
109
|
+
|
|
54
110
|
private
|
|
55
111
|
|
|
56
112
|
def subscribe_to_console_logs
|
|
@@ -83,9 +139,6 @@ module Capybara
|
|
|
83
139
|
TURBO_SENTINEL_PREFIX = "__lightpanda_turbo_"
|
|
84
140
|
private_constant :TURBO_SENTINEL_PREFIX
|
|
85
141
|
|
|
86
|
-
PAGE_ERROR_SENTINEL_PREFIX = "__lightpanda_page_error_"
|
|
87
|
-
private_constant :PAGE_ERROR_SENTINEL_PREFIX
|
|
88
|
-
|
|
89
142
|
# The Turbo activity tracker signals busy/idle via console.debug
|
|
90
143
|
# sentinels (see subscribe_to_turbo_signals); every consoleAPICalled
|
|
91
144
|
# consumer must filter them out of user-facing output.
|
|
@@ -93,17 +146,12 @@ module Capybara
|
|
|
93
146
|
value.is_a?(String) && value.start_with?(TURBO_SENTINEL_PREFIX)
|
|
94
147
|
end
|
|
95
148
|
|
|
96
|
-
# javascripts/errors.js reports uncaught page errors over the same
|
|
97
|
-
# console.debug channel, prefix + JSON payload.
|
|
98
|
-
def page_error_sentinel?(value)
|
|
99
|
-
value.is_a?(String) && value.start_with?(PAGE_ERROR_SENTINEL_PREFIX)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
149
|
# Anything the injected bundle emits for the driver's own benefit. Every
|
|
103
|
-
# consoleAPICalled consumer filters on this, not on one prefix —
|
|
104
|
-
#
|
|
150
|
+
# consoleAPICalled consumer filters on this, not on one prefix — a new
|
|
151
|
+
# sentinel added to turbo_sentinel?'s side only would leak driver
|
|
152
|
+
# plumbing into user-facing output.
|
|
105
153
|
def driver_sentinel?(value)
|
|
106
|
-
turbo_sentinel?(value)
|
|
154
|
+
turbo_sentinel?(value)
|
|
107
155
|
end
|
|
108
156
|
|
|
109
157
|
# Oldest entries are dropped past this cap so a chatty page can't grow
|
|
@@ -113,20 +161,12 @@ module Capybara
|
|
|
113
161
|
# Ring-buffer every console.* call for `Browser#console_logs`. Separate
|
|
114
162
|
# from subscribe_to_console_logs (which streams to an optional IO logger)
|
|
115
163
|
# so capture works without any logger configured. Driver sentinels are
|
|
116
|
-
# plumbing, not page output
|
|
117
|
-
# page-error ones are rerouted to @page_errors instead.
|
|
164
|
+
# plumbing, not page output, so they are dropped.
|
|
118
165
|
def subscribe_to_console_capture
|
|
119
166
|
on("Runtime.consoleAPICalled") do |params|
|
|
120
167
|
args = params["args"]
|
|
121
168
|
next unless args.is_a?(Array)
|
|
122
|
-
|
|
123
|
-
first = args.first&.dig("value")
|
|
124
|
-
next if turbo_sentinel?(first)
|
|
125
|
-
|
|
126
|
-
if page_error_sentinel?(first)
|
|
127
|
-
record_page_error(first, params["timestamp"])
|
|
128
|
-
next
|
|
129
|
-
end
|
|
169
|
+
next if driver_sentinel?(args.first&.dig("value"))
|
|
130
170
|
|
|
131
171
|
entry = {
|
|
132
172
|
type: params["type"],
|
|
@@ -142,28 +182,10 @@ module Capybara
|
|
|
142
182
|
# loop (a rejected fetch on an interval) must not grow this unbounded.
|
|
143
183
|
PAGE_ERRORS_LIMIT = 1_000
|
|
144
184
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
# this exists to end — so it lands with the raw text as the message.
|
|
149
|
-
def record_page_error(sentinel, timestamp)
|
|
150
|
-
json = sentinel.delete_prefix(PAGE_ERROR_SENTINEL_PREFIX)
|
|
151
|
-
fields = begin
|
|
152
|
-
JSON.parse(json)
|
|
153
|
-
rescue JSON::ParserError
|
|
154
|
-
{ "kind" => "error", "message" => json }
|
|
185
|
+
def subscribe_to_page_errors
|
|
186
|
+
on("Runtime.exceptionThrown") do |params|
|
|
187
|
+
push_capped(@page_errors, @page_errors_mutex, Console.page_error_entry(params), PAGE_ERRORS_LIMIT)
|
|
155
188
|
end
|
|
156
|
-
|
|
157
|
-
entry = {
|
|
158
|
-
kind: fields["kind"] || "error",
|
|
159
|
-
message: fields["message"].to_s,
|
|
160
|
-
url: fields["url"].to_s,
|
|
161
|
-
line: fields["line"],
|
|
162
|
-
column: fields["column"],
|
|
163
|
-
stack: fields["stack"],
|
|
164
|
-
timestamp: timestamp,
|
|
165
|
-
}
|
|
166
|
-
push_capped(@page_errors, @page_errors_mutex, entry, PAGE_ERRORS_LIMIT)
|
|
167
189
|
end
|
|
168
190
|
|
|
169
191
|
def push_capped(buffer, mutex, entry, limit)
|
|
@@ -10,10 +10,17 @@ module Capybara
|
|
|
10
10
|
# Find elements in the current context (top frame or active frame).
|
|
11
11
|
# Returns an array of remote object ID strings.
|
|
12
12
|
def find(method, selector)
|
|
13
|
+
find_hinted(method, selector).map(&:first)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Like #find, but with Capybara's finder hints answered in the same
|
|
17
|
+
# round-trip (see `_lightpanda.findHints`). Returns `[objectId, cache]`
|
|
18
|
+
# pairs; `cache` is {} when `spec` is nil or the page couldn't answer.
|
|
19
|
+
def find_hinted(method, selector, spec = nil)
|
|
13
20
|
if @frame_stack.empty?
|
|
14
|
-
find_in_document(method, selector)
|
|
21
|
+
find_in_document(method, selector, spec)
|
|
15
22
|
else
|
|
16
|
-
find_in_frame(method, selector)
|
|
23
|
+
find_in_frame(method, selector, spec)
|
|
17
24
|
end
|
|
18
25
|
end
|
|
19
26
|
|
|
@@ -27,9 +34,14 @@ module Capybara
|
|
|
27
34
|
# `NoExecutionContextError`. `find_in_document` and `find_in_frame`
|
|
28
35
|
# already use the same wrapper; `find_within` was the odd one out.
|
|
29
36
|
def find_within(remote_object_id, method, selector)
|
|
37
|
+
find_within_hinted(remote_object_id, method, selector).map(&:first)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def find_within_hinted(remote_object_id, method, selector, spec = nil)
|
|
30
41
|
with_default_context_wait do
|
|
31
|
-
result = call_function_on(remote_object_id, FIND_WITHIN_JS, method, selector,
|
|
32
|
-
|
|
42
|
+
result = call_function_on(remote_object_id, FIND_WITHIN_JS, method, selector, spec,
|
|
43
|
+
return_by_value: false)
|
|
44
|
+
extract_hinted_nodes(result)
|
|
33
45
|
end
|
|
34
46
|
rescue JavaScriptError => e
|
|
35
47
|
raise_invalid_selector(e, method, selector)
|
|
@@ -37,12 +49,12 @@ module Capybara
|
|
|
37
49
|
|
|
38
50
|
# Ancestor chain of `remote_object_id` from parentNode up to (but
|
|
39
51
|
# excluding) `document`, returned as an array of remote object IDs.
|
|
40
|
-
#
|
|
41
|
-
#
|
|
52
|
+
# Same `with_default_context_wait` wrapping as `find_within` — same
|
|
53
|
+
# race window applies.
|
|
42
54
|
def parents_of(remote_object_id)
|
|
43
55
|
with_default_context_wait do
|
|
44
56
|
result = call_function_on(remote_object_id, PARENTS_JS, return_by_value: false)
|
|
45
|
-
|
|
57
|
+
extract_hinted_nodes(result).map(&:first)
|
|
46
58
|
end
|
|
47
59
|
end
|
|
48
60
|
|
|
@@ -50,8 +62,8 @@ module Capybara
|
|
|
50
62
|
|
|
51
63
|
# Sentinel string thrown from FIND_*_JS when querySelectorAll rejects a
|
|
52
64
|
# malformed selector, so the Ruby side can convert JavaScriptError into
|
|
53
|
-
# Capybara::Lightpanda::InvalidSelector.
|
|
54
|
-
#
|
|
65
|
+
# Capybara::Lightpanda::InvalidSelector. A plain prefixed string keeps
|
|
66
|
+
# our inline JS simple.
|
|
55
67
|
INVALID_SELECTOR_MARKER = "LIGHTPANDA_INVALID_SELECTOR:"
|
|
56
68
|
|
|
57
69
|
# The find algorithms exist in three dispatch contexts — element-scoped
|
|
@@ -90,32 +102,39 @@ module Capybara
|
|
|
90
102
|
# JS function for finding elements within a node.
|
|
91
103
|
# Works in any execution context (top frame or iframe).
|
|
92
104
|
FIND_WITHIN_JS = <<~JS.freeze
|
|
93
|
-
function(method, selector) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
105
|
+
function(method, selector, spec) {
|
|
106
|
+
var self = this;
|
|
107
|
+
var nodes = (function() {
|
|
108
|
+
if (method === 'xpath') {
|
|
109
|
+
#{format(XPATH_FIND_FRAGMENT, doc: 'self.ownerDocument', root: 'self', selector: 'selector')}
|
|
110
|
+
}
|
|
111
|
+
#{format(CSS_FIND_FRAGMENT, target: 'self', selector: 'selector')}
|
|
112
|
+
})();
|
|
113
|
+
return spec ? _lightpanda.findHints(nodes, spec) : nodes;
|
|
98
114
|
}
|
|
99
115
|
JS
|
|
100
116
|
private_constant :FIND_WITHIN_JS
|
|
101
117
|
|
|
102
118
|
# JS function for finding elements in an iframe's contentDocument.
|
|
103
119
|
FIND_IN_FRAME_JS = <<~JS.freeze
|
|
104
|
-
function(method, selector) {
|
|
120
|
+
function(method, selector, spec) {
|
|
105
121
|
var doc;
|
|
106
122
|
try { doc = this.contentDocument || (this.contentWindow && this.contentWindow.document); } catch(e) {}
|
|
107
123
|
if (!doc) return [];
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
124
|
+
var nodes = (function() {
|
|
125
|
+
if (method === 'xpath') {
|
|
126
|
+
#{format(XPATH_FIND_FRAGMENT, doc: 'doc', root: 'doc', selector: 'selector')}
|
|
127
|
+
}
|
|
128
|
+
#{format(CSS_FIND_FRAGMENT, target: 'doc', selector: 'selector')}
|
|
129
|
+
})();
|
|
130
|
+
return spec ? _lightpanda.findHints(nodes, spec) : nodes;
|
|
112
131
|
}
|
|
113
132
|
JS
|
|
114
133
|
private_constant :FIND_IN_FRAME_JS
|
|
115
134
|
|
|
116
135
|
# Walks `parentNode` from `this` up to (but excluding) `document`,
|
|
117
136
|
# returning the chain as a JS array. Each entry is an element node so
|
|
118
|
-
# `
|
|
137
|
+
# `extract_hinted_nodes` can wrap them as Lightpanda::Nodes.
|
|
119
138
|
PARENTS_JS = <<~JS
|
|
120
139
|
function() {
|
|
121
140
|
var nodes = [];
|
|
@@ -129,7 +148,7 @@ module Capybara
|
|
|
129
148
|
JS
|
|
130
149
|
private_constant :PARENTS_JS
|
|
131
150
|
|
|
132
|
-
def find_in_document(method, selector)
|
|
151
|
+
def find_in_document(method, selector, spec)
|
|
133
152
|
with_default_context_wait do
|
|
134
153
|
# Coerce Symbol selectors (e.g. Capybara warning path lets `have_css(:p)`
|
|
135
154
|
# through) to a string before quoting. Symbol#inspect returns `:p`,
|
|
@@ -143,19 +162,21 @@ module Capybara
|
|
|
143
162
|
else
|
|
144
163
|
format(CSS_FIND_FRAGMENT, target: "document", selector: selector_literal)
|
|
145
164
|
end
|
|
146
|
-
|
|
147
|
-
|
|
165
|
+
nodes = "(function() { #{fragment} })()"
|
|
166
|
+
nodes = "_lightpanda.findHints(#{nodes}, #{JSON.generate(spec)})" if spec
|
|
167
|
+
result = evaluate_with_ref(nodes)
|
|
168
|
+
extract_hinted_nodes(result)
|
|
148
169
|
end
|
|
149
170
|
rescue JavaScriptError => e
|
|
150
171
|
raise_invalid_selector(e, method, selector)
|
|
151
172
|
end
|
|
152
173
|
|
|
153
|
-
def find_in_frame(method, selector)
|
|
174
|
+
def find_in_frame(method, selector, spec)
|
|
154
175
|
with_default_context_wait do
|
|
155
176
|
frame_node = @frame_stack.last
|
|
156
|
-
result = call_function_on(frame_node.remote_object_id, FIND_IN_FRAME_JS, method, selector,
|
|
177
|
+
result = call_function_on(frame_node.remote_object_id, FIND_IN_FRAME_JS, method, selector, spec,
|
|
157
178
|
return_by_value: false)
|
|
158
|
-
|
|
179
|
+
extract_hinted_nodes(result)
|
|
159
180
|
end
|
|
160
181
|
rescue JavaScriptError => e
|
|
161
182
|
raise_invalid_selector(e, method, selector)
|
|
@@ -169,27 +190,57 @@ module Capybara
|
|
|
169
190
|
raise js_error
|
|
170
191
|
end
|
|
171
192
|
|
|
172
|
-
# Extract
|
|
193
|
+
# Extract `[objectId, cache]` pairs from a remote array reference. The
|
|
194
|
+
# hints, when findHints ran, are a JSON string on the array's own
|
|
195
|
+
# `__lightpandaHints` property, read from the same getProperties call.
|
|
173
196
|
# `ensure release_object` so the outer array handle is freed even when
|
|
174
197
|
# property walking raises — without this, a transient CDP error during
|
|
175
198
|
# property enumeration leaks one V8 handle per failed find call.
|
|
176
|
-
def
|
|
199
|
+
def extract_hinted_nodes(result)
|
|
177
200
|
return [] unless result && result["objectId"]
|
|
178
201
|
|
|
179
202
|
outer_id = result["objectId"]
|
|
180
203
|
begin
|
|
181
204
|
props = get_object_properties(outer_id)
|
|
182
205
|
properties = props["result"] || []
|
|
183
|
-
properties
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
206
|
+
ids = properties
|
|
207
|
+
.select { |p| p["name"] =~ /\A\d+\z/ }
|
|
208
|
+
.sort_by { |p| p["name"].to_i }
|
|
209
|
+
.filter_map { |p| p.dig("value", "objectId") }
|
|
210
|
+
ids.zip(parse_hints(properties, ids.size))
|
|
187
211
|
rescue Error
|
|
188
212
|
[]
|
|
189
213
|
ensure
|
|
190
214
|
release_object(outer_id)
|
|
191
215
|
end
|
|
192
216
|
end
|
|
217
|
+
|
|
218
|
+
# One cache per node, keyed the way Capybara reads #initial_cache. A
|
|
219
|
+
# missing, malformed or mis-sized payload yields empty caches, so
|
|
220
|
+
# Capybara asks each node itself — never a wrong answer.
|
|
221
|
+
def parse_hints(properties, count)
|
|
222
|
+
raw = properties.find { |p| p["name"] == "__lightpandaHints" }&.dig("value", "value")
|
|
223
|
+
hints = decode_hints(raw)
|
|
224
|
+
return Array.new(count) { {} } unless hints&.size == count
|
|
225
|
+
|
|
226
|
+
hints.map { |h| symbolize_hint(h) }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Capybara may consume these after a DOM mutation during lazy Result
|
|
230
|
+
# enumeration. Accept only well-typed immutable answers, never mutable
|
|
231
|
+
# visibility/style/position or a payload from an incompatible helper.
|
|
232
|
+
def symbolize_hint(hint)
|
|
233
|
+
return {} unless hint.is_a?(Hash) && hint.keys == ["tag_name"] && hint["tag_name"].is_a?(String)
|
|
234
|
+
|
|
235
|
+
{ tag_name: hint["tag_name"] }
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def decode_hints(raw)
|
|
239
|
+
hints = raw.is_a?(String) ? JSON.parse(raw) : nil
|
|
240
|
+
hints.is_a?(Array) ? hints : nil
|
|
241
|
+
rescue JSON::ParserError
|
|
242
|
+
nil
|
|
243
|
+
end
|
|
193
244
|
end
|
|
194
245
|
end
|
|
195
246
|
end
|
|
@@ -57,8 +57,8 @@ module Capybara
|
|
|
57
57
|
# Surface a dialog that opened with no pre-arm. Called from the main
|
|
58
58
|
# thread after every action that can open one (Browser#wait_for_idle,
|
|
59
59
|
# #go_to). Warns by default; raises when the driver was built with
|
|
60
|
-
# `raise_on_unhandled_modal: true
|
|
61
|
-
#
|
|
60
|
+
# `raise_on_unhandled_modal: true`. Either way the dialog is already
|
|
61
|
+
# gone — Lightpanda's default resolved it.
|
|
62
62
|
def check_unhandled_modal!
|
|
63
63
|
entry = @modal_messages_mutex.synchronize do
|
|
64
64
|
e = @unhandled_modal
|
|
@@ -74,11 +74,12 @@ module Capybara
|
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
# `type` is accepted for the error message only: like Selenium (where
|
|
77
|
-
# alert/confirm are indistinguishable) and
|
|
78
|
-
# accepts whatever fires), we deliberately do NOT
|
|
79
|
-
# reported type differs from the one Capybara
|
|
80
|
-
# wrap `data-confirm` deletes in `accept_alert`
|
|
81
|
-
# expect it to work; only the message text is
|
|
77
|
+
# alert/confirm are indistinguishable) and other CDP drivers (whose
|
|
78
|
+
# dialog handler accepts whatever fires), we deliberately do NOT
|
|
79
|
+
# reject a dialog whose reported type differs from the one Capybara
|
|
80
|
+
# asked for. Real suites wrap `data-confirm` deletes in `accept_alert`
|
|
81
|
+
# (e.g. solidus admin) and expect it to work; only the message text is
|
|
82
|
+
# matched.
|
|
82
83
|
def find_modal(type, text: nil, wait: options.timeout)
|
|
83
84
|
regexp = text.is_a?(Regexp) ? text : (text && Regexp.new(Regexp.escape(text.to_s)))
|
|
84
85
|
last_seen_message = nil
|
|
@@ -90,7 +90,16 @@ module Capybara
|
|
|
90
90
|
|
|
91
91
|
return unless @client.closed?
|
|
92
92
|
|
|
93
|
-
raise DeadBrowserError, "Lightpanda crashed navigating to #{url}"
|
|
93
|
+
raise DeadBrowserError, "Lightpanda crashed navigating to #{url}#{crash_output}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# The browser's own last words, when we spawned it: a panic or an
|
|
97
|
+
# assertion only ever shows up on its stderr.
|
|
98
|
+
def crash_output
|
|
99
|
+
lines = @process&.output&.last(20)
|
|
100
|
+
return "" if lines.nil? || lines.empty?
|
|
101
|
+
|
|
102
|
+
"\nLast Lightpanda output:\n#{lines.join("\n")}"
|
|
94
103
|
end
|
|
95
104
|
|
|
96
105
|
def safe_current_url
|
|
@@ -271,10 +271,10 @@ module Capybara
|
|
|
271
271
|
# Cheaper than walking properties and good enough for shared specs. Releases
|
|
272
272
|
# the original handle so long-lived sessions don't accumulate leaked objectIds.
|
|
273
273
|
#
|
|
274
|
-
# V8 refuses to return a cyclic object by value.
|
|
274
|
+
# V8 refuses to return a cyclic object by value. Below 10255 every
|
|
275
275
|
# Zig-backed object, `window` included, came back as a node; since
|
|
276
|
-
# upstream #3831 (
|
|
277
|
-
# and a plain self-referencing object
|
|
276
|
+
# upstream #3831 (in the floor) `window` lands here and raised. Both it
|
|
277
|
+
# and a plain self-referencing object return CyclicObject.
|
|
278
278
|
def serialize_remote_object(object_id)
|
|
279
279
|
json = page_command(
|
|
280
280
|
"Runtime.callFunctionOn",
|
|
@@ -165,6 +165,7 @@ module Capybara
|
|
|
165
165
|
@turbo_event.set
|
|
166
166
|
subscribe_to_console_logs
|
|
167
167
|
subscribe_to_console_capture
|
|
168
|
+
subscribe_to_page_errors
|
|
168
169
|
subscribe_to_execution_context
|
|
169
170
|
subscribe_to_navigation
|
|
170
171
|
subscribe_to_document_ready
|
|
@@ -187,7 +188,7 @@ module Capybara
|
|
|
187
188
|
end
|
|
188
189
|
|
|
189
190
|
# Download destination: explicit :save_path option wins, else
|
|
190
|
-
# Capybara.save_path
|
|
191
|
+
# Capybara.save_path. nil => downloads stay off.
|
|
191
192
|
def configured_download_path
|
|
192
193
|
@options.save_path ||
|
|
193
194
|
(defined?(Capybara) && Capybara.respond_to?(:save_path) ? Capybara.save_path : nil)
|
|
@@ -553,8 +554,15 @@ module Capybara
|
|
|
553
554
|
page_command("LP.configureLoading", subFrame: true, worker: true)
|
|
554
555
|
end
|
|
555
556
|
|
|
557
|
+
# Registration only covers documents created *after* it, and the target
|
|
558
|
+
# already holds its about:blank document. Evaluate the bundle there too,
|
|
559
|
+
# or a session that runs `find`/`click` before its first `visit` (DOM built
|
|
560
|
+
# by execute_script, or right after reset_session!) hits
|
|
561
|
+
# `ReferenceError: _lightpanda is not defined`. The bundle's own
|
|
562
|
+
# `if (window._lightpanda) return;` guard makes the second run a no-op.
|
|
556
563
|
def register_auto_scripts
|
|
557
564
|
page_command("Page.addScriptToEvaluateOnNewDocument", source: AutoScripts::JS)
|
|
565
|
+
page_command("Runtime.evaluate", expression: AutoScripts::JS)
|
|
558
566
|
end
|
|
559
567
|
|
|
560
568
|
# Apply the `window_size` option as a JS-visible viewport: it drives
|
|
@@ -687,9 +695,10 @@ module Capybara
|
|
|
687
695
|
# After a click that navigated, the next action must not run before the
|
|
688
696
|
# new page's JS has wired its handlers: Stimulus, Bootstrap's data-api
|
|
689
697
|
# and Turbo load as module scripts, which run after the document commits
|
|
690
|
-
# and before DOMContentLoaded.
|
|
691
|
-
# event is awaited briefly, then document.readyState is
|
|
692
|
-
# rest of the budget, as #go_to does
|
|
698
|
+
# and before DOMContentLoaded. Chrome-based drivers wait for the load
|
|
699
|
+
# event. The event is awaited briefly, then document.readyState is
|
|
700
|
+
# polled for the rest of the budget, as #go_to does
|
|
701
|
+
# (lightpanda-io/browser#1801: page
|
|
693
702
|
# events may never come). Never raises: a page that never settles costs
|
|
694
703
|
# the timeout once, then the action returns as it did before.
|
|
695
704
|
DOCUMENT_READY_EVENT_WAIT = 2
|
|
@@ -214,12 +214,31 @@ module Capybara
|
|
|
214
214
|
raise TimeoutError, "WebSocket handshake timed out after #{@options.handshake_timeout}s"
|
|
215
215
|
end
|
|
216
216
|
|
|
217
|
+
# A JS string can hold a lone UTF-16 surrogate ("😀".slice(0, 1));
|
|
218
|
+
# Lightpanda escapes it as `\ud83d`, which Ruby's json rejects.
|
|
219
|
+
# Dropping that frame left its command waiting out the full timeout,
|
|
220
|
+
# so retry once with every lone surrogate replaced by U+FFFD — what
|
|
221
|
+
# Chrome and Ferrum (#648) hand back for the same value.
|
|
217
222
|
def parse_message(data)
|
|
218
223
|
JSON.parse(data, max_nesting: false)
|
|
219
224
|
rescue JSON::ParserError => e
|
|
220
|
-
|
|
225
|
+
begin
|
|
226
|
+
JSON.parse(replace_lone_surrogates(data), max_nesting: false)
|
|
227
|
+
rescue JSON::ParserError
|
|
228
|
+
warn_parse_failure(e.message)
|
|
221
229
|
|
|
222
|
-
|
|
230
|
+
nil
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Alternation order matters: a valid lead+trail pair is consumed
|
|
235
|
+
# whole, then a lone surrogate, then any other escape (`\\`, `\"`,
|
|
236
|
+
# a non-surrogate `\uXXXX`) is skipped as-is — scanning left to
|
|
237
|
+
# right so `\\ud800` (an escaped backslash, then text) survives.
|
|
238
|
+
SURROGATE_ESCAPE = /\\u[dD][89abAB]\h{2}\\u[dD][c-fC-F]\h{2}|(\\u[dD][89a-fA-F]\h{2})|\\./m
|
|
239
|
+
|
|
240
|
+
def replace_lone_surrogates(data)
|
|
241
|
+
data.gsub(SURROGATE_ESCAPE) { |escape| Regexp.last_match(1) ? "\\uFFFD" : escape }
|
|
223
242
|
end
|
|
224
243
|
|
|
225
244
|
# Dedupe identical parse-failure warnings per WebSocket instance.
|