capybara-lightpanda 0.8.0 → 0.9.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 +25 -0
- data/lib/capybara/lightpanda/auto_scripts.rb +28 -2
- data/lib/capybara/lightpanda/binary.rb +2 -46
- data/lib/capybara/lightpanda/browser/console.rb +108 -0
- data/lib/capybara/lightpanda/browser/finder.rb +196 -0
- data/lib/capybara/lightpanda/browser/modals.rb +99 -0
- data/lib/capybara/lightpanda/browser/navigation.rb +185 -0
- data/lib/capybara/lightpanda/browser/runtime.rb +258 -0
- data/lib/capybara/lightpanda/browser.rb +61 -816
- data/lib/capybara/lightpanda/client/subscriber.rb +2 -0
- data/lib/capybara/lightpanda/client/web_socket.rb +19 -21
- data/lib/capybara/lightpanda/client.rb +5 -4
- data/lib/capybara/lightpanda/driver.rb +21 -36
- data/lib/capybara/lightpanda/errors.rb +19 -10
- data/lib/capybara/lightpanda/javascripts/attach.js +16 -0
- data/lib/capybara/lightpanda/javascripts/banner.js +15 -0
- data/lib/capybara/lightpanda/javascripts/predicates.js +152 -0
- data/lib/capybara/lightpanda/javascripts/turbo.js +67 -0
- data/lib/capybara/lightpanda/keyboard.rb +23 -19
- data/lib/capybara/lightpanda/network.rb +72 -13
- data/lib/capybara/lightpanda/node.rb +24 -31
- data/lib/capybara/lightpanda/options.rb +4 -0
- data/lib/capybara/lightpanda/process.rb +3 -18
- data/lib/capybara/lightpanda/version.rb +1 -1
- metadata +10 -2
- data/lib/capybara/lightpanda/javascripts/index.js +0 -226
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a7ec772a2b4254837394ec6705d94c8759e750f0f2ba7ce12ac4d02bfdd30bf5
|
|
4
|
+
data.tar.gz: a65953b578a45f0f652f0f3dadc2334bfa6db4ae6c6f4d931da4d9730f3656a1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 95af3574b27e16d24845f3bcdacc78779c8a036a81d7ab68870de8fd8bc957b8557a2beb9812f85030e7e1e74d994abf2f0d43e116ebb3c87069c52006012429
|
|
7
|
+
data.tar.gz: 20bf49585073a9d06e76da5460aeb143d653de2dab9a7d1a11d55b16603e9d956737147d712403416f3a8818967193e3150ebc7ae04dbf248ab52b3c62a40495
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.9.0] - 2026-06-18
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- `send_keys(:ctrl, …)` (held or `[:ctrl, "a"]` array form) crashed with `NoMethodError` — `MODIFIERS` advertised `:ctrl` but `KEYS` lacked the entry. Both forms now dispatch Control correctly, and an unknown key symbol raises `ArgumentError` naming the key everywhere.
|
|
8
|
+
- `Driver#headers` no longer reports phantom values after `reset!` — the cached `extra_headers` are cleared with the disposed BrowserContext.
|
|
9
|
+
- A connection reset (RST) during the WebSocket handshake now raises `DeadBrowserError` like the FIN path instead of leaking a raw `Errno::ECONNRESET`.
|
|
10
|
+
- A duplicate CDP response frame for an already-answered command id no longer kills the process (`IVar#try_set` on the message thread).
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- `Browser` is now composed of include-modules (`Browser::Runtime` / `Finder` / `Navigation` / `Modals` / `Console`) — pure code motion, public API unchanged (verified method-for-method by reflection).
|
|
15
|
+
- The Network CDP domain has a single owner: `Network` captures the navigation response behind `Browser#status_code` / `#response_headers`, and traffic tracking is always on (cleared per `reset`, ferrum parity). `driver.network.disable` now visibly owns the caveat that it freezes status tracking.
|
|
16
|
+
- Port-in-use recovery dispatches on a typed `PortInUseError` (subclass of `ProcessTimeoutError`, so existing rescues keep working) instead of matching the error message; HTTP download failures raise `BinaryError` instead of `BinaryNotFoundError`.
|
|
17
|
+
- `Driver#reset!` rescues the gem's error hierarchy (plus `SystemCallError`/`IOError`) instead of `StandardError`, and warns on respawn — programmer errors no longer degrade into a silent browser restart per test.
|
|
18
|
+
- `Node#shadow_root` routes through the guarded `#call` path: reading the shadow root of a detached host now raises `ObsoleteNode` (handled by Capybara's `automatic_reload`) instead of silently returning stale content.
|
|
19
|
+
- Arrays without modifier symbols passed to `send_keys` now type via `insertText`, consistent with plain strings (previously synthesized per-char keyDown/keyUp).
|
|
20
|
+
|
|
21
|
+
- No-args `evaluate_script` / `execute_script` send the expression with `replMode: true` (DevTools-console REPL semantics) instead of wrapping it in an IIFE. Top-level `const`/`let` can now be redeclared across calls *and* state persists between calls, matching what users see in the Chrome console. JS exceptions still raise `JavaScriptError`.
|
|
22
|
+
|
|
23
|
+
### Removed
|
|
24
|
+
|
|
25
|
+
- Dead internal API swept (pre-1.0 cleanup; none had a caller or documented use): `Binary.run` / `.exec` / `.fetch` / `.version` / `.path` and the `Binary::Result` struct; `Browser#document_node_id`; `Client#ws_url` / `#options` readers; `WebSocket#open?`. Cuprite/Ferrum drop-in surface is deliberately KEPT and documented: `Options#window_size` / `#headless` (accepted, inert) and the never-raised `MouseEventFailed` / `NoSuchPageError` / `StatusError` (peer-taxonomy mirrors so migrated rescue lists keep loading).
|
|
26
|
+
- UPSTREAM_BUGS.md Bug #9 (`requestSubmit()` threw when a listener canceled the SubmitEvent) retired: fixed upstream, verified on the nightly this gem already requires. Contract tests pin both retired bugs in `test/features/upstream_bugs_test.rb`.
|
|
27
|
+
|
|
3
28
|
## [0.8.0] - 2026-06-12
|
|
4
29
|
|
|
5
30
|
> **Update Lightpanda before upgrading.** Requires a nightly build ≥ 6736 (published 2026-06-12). The driver refuses to start against older binaries.
|
|
@@ -2,9 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
module Capybara
|
|
4
4
|
module Lightpanda
|
|
5
|
+
# Assembles the `_lightpanda` bundle injected once per session via
|
|
6
|
+
# Page.addScriptToEvaluateOnNewDocument (Browser#create_page).
|
|
7
|
+
#
|
|
8
|
+
# The bundle is split across plain-declaration source files in
|
|
9
|
+
# javascripts/ — none of which contain an IIFE or module syntax — so each
|
|
10
|
+
# file is readable in isolation and parses identically as a classic
|
|
11
|
+
# browser script and as a `new Function(...)` body (how the Bun harness in
|
|
12
|
+
# test/js/ loads predicates.js without a build step). This module is the
|
|
13
|
+
# "linker": it concatenates the parts in order and wraps them in the IIFE
|
|
14
|
+
# plus the idempotency guard.
|
|
5
15
|
module AutoScripts
|
|
6
|
-
|
|
7
|
-
|
|
16
|
+
JS_DIR = File.expand_path("javascripts", __dir__).freeze
|
|
17
|
+
|
|
18
|
+
# Order matters: declarations (turbo, predicates) before the wiring
|
|
19
|
+
# (attach) that reads their names. banner is a leading comment block.
|
|
20
|
+
PARTS = %w[banner.js turbo.js predicates.js attach.js].freeze
|
|
21
|
+
|
|
22
|
+
# The guard short-circuits a repeat run before turbo.js can register its
|
|
23
|
+
# listeners a second time (double-registration would double-count
|
|
24
|
+
# _pendingTurboOps and desync the busy/idle sentinels console.rb reads).
|
|
25
|
+
# window._lightpanda is only set by attach.js (last), so the guard
|
|
26
|
+
# reflects "a previous full run completed".
|
|
27
|
+
JS = begin
|
|
28
|
+
body = PARTS.map { |name| File.read(File.join(JS_DIR, name)) }.join("\n")
|
|
29
|
+
"(function() {\n" \
|
|
30
|
+
"if (window._lightpanda) return;\n" \
|
|
31
|
+
"#{body}\n" \
|
|
32
|
+
"})();\n"
|
|
33
|
+
end.freeze
|
|
8
34
|
end
|
|
9
35
|
end
|
|
10
36
|
end
|
|
@@ -9,20 +9,6 @@ require "uri"
|
|
|
9
9
|
module Capybara
|
|
10
10
|
module Lightpanda
|
|
11
11
|
class Binary
|
|
12
|
-
Result = Struct.new(:stdout, :stderr, :status) do
|
|
13
|
-
def success?
|
|
14
|
-
status.success?
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def exit_code
|
|
18
|
-
status.exitstatus
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def output
|
|
22
|
-
stdout.empty? ? stderr : stdout
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
12
|
GITHUB_RELEASE_URL = "https://github.com/lightpanda-io/browser/releases/download"
|
|
27
13
|
|
|
28
14
|
PLATFORMS = {
|
|
@@ -71,10 +57,6 @@ module Capybara
|
|
|
71
57
|
yield self
|
|
72
58
|
end
|
|
73
59
|
|
|
74
|
-
def path
|
|
75
|
-
@path ||= update
|
|
76
|
-
end
|
|
77
|
-
|
|
78
60
|
# Canonical entrypoint: ensure the binary at install_path is current,
|
|
79
61
|
# download if needed, return its path. Pinned (required_version set)
|
|
80
62
|
# never re-downloads when present. Unpinned re-downloads when older
|
|
@@ -145,7 +127,6 @@ module Capybara
|
|
|
145
127
|
end
|
|
146
128
|
|
|
147
129
|
File.delete(path)
|
|
148
|
-
@path = nil
|
|
149
130
|
log("Removed #{path}")
|
|
150
131
|
path
|
|
151
132
|
end
|
|
@@ -162,30 +143,6 @@ module Capybara
|
|
|
162
143
|
nil
|
|
163
144
|
end
|
|
164
145
|
|
|
165
|
-
def run(*)
|
|
166
|
-
stdout, stderr, status = Open3.capture3(path, *)
|
|
167
|
-
|
|
168
|
-
Result.new(stdout: stdout, stderr: stderr, status: status)
|
|
169
|
-
rescue Errno::ENOENT
|
|
170
|
-
raise BinaryNotFoundError, "Lightpanda binary not found"
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
def exec(*)
|
|
174
|
-
Kernel.exec(path, *)
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def fetch(url)
|
|
178
|
-
result = run("fetch", "--dump", url)
|
|
179
|
-
raise BinaryError, result.stderr unless result.success?
|
|
180
|
-
|
|
181
|
-
result.stdout
|
|
182
|
-
end
|
|
183
|
-
|
|
184
|
-
def version
|
|
185
|
-
result = run("version")
|
|
186
|
-
result.output.strip
|
|
187
|
-
end
|
|
188
|
-
|
|
189
146
|
def download
|
|
190
147
|
binary_name = platform_binary
|
|
191
148
|
tag = required_version || "nightly"
|
|
@@ -197,7 +154,6 @@ module Capybara
|
|
|
197
154
|
|
|
198
155
|
download_file(url, destination)
|
|
199
156
|
FileUtils.chmod(0o755, destination)
|
|
200
|
-
@path = destination
|
|
201
157
|
|
|
202
158
|
destination
|
|
203
159
|
end
|
|
@@ -314,7 +270,7 @@ module Capybara
|
|
|
314
270
|
end
|
|
315
271
|
|
|
316
272
|
def follow_redirects(uri, destination, limit = 10)
|
|
317
|
-
raise
|
|
273
|
+
raise BinaryError, "Too many redirects" if limit.zero?
|
|
318
274
|
|
|
319
275
|
http_start(uri) do |http|
|
|
320
276
|
request = Net::HTTP::Get.new(uri)
|
|
@@ -329,7 +285,7 @@ module Capybara
|
|
|
329
285
|
log("Redirected → #{response['location']}")
|
|
330
286
|
follow_redirects(URI.parse(response["location"]), destination, limit - 1)
|
|
331
287
|
else
|
|
332
|
-
raise
|
|
288
|
+
raise BinaryError, "Failed to download binary: #{response.code} #{response.message}"
|
|
333
289
|
end
|
|
334
290
|
end
|
|
335
291
|
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capybara
|
|
4
|
+
module Lightpanda
|
|
5
|
+
class Browser
|
|
6
|
+
# Runtime.consoleAPICalled consumers: the user-facing console_logs
|
|
7
|
+
# ring buffer, the optional IO-logger stream, and the Turbo
|
|
8
|
+
# busy/idle sentinel tracking behind Browser#wait_for_idle.
|
|
9
|
+
module Console
|
|
10
|
+
# Console messages captured from `Runtime.consoleAPICalled` since the
|
|
11
|
+
# last `reset` (Turbo-tracker sentinels excluded). Loose hashes, like
|
|
12
|
+
# Network#traffic: `{type:, text:, timestamp:, args:}` where `type` is
|
|
13
|
+
# the console method name ("log", "error", "warning", ...), `text` joins
|
|
14
|
+
# the arguments' primitive values/descriptions, and `args` keeps the raw
|
|
15
|
+
# CDP RemoteObjects. Lets suites assert on JS console errors
|
|
16
|
+
# (`browser.console_logs.select { |m| m[:type] == "error" }`) the way
|
|
17
|
+
# peer drivers do via custom Ferrum loggers.
|
|
18
|
+
def console_logs
|
|
19
|
+
@console_logs_mutex.synchronize { @console_logs.dup }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def clear_console_logs
|
|
23
|
+
@console_logs_mutex.synchronize { @console_logs.clear }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def subscribe_to_console_logs
|
|
29
|
+
logger = @options.logger
|
|
30
|
+
return unless logger
|
|
31
|
+
|
|
32
|
+
on("Runtime.consoleAPICalled") do |params|
|
|
33
|
+
params["args"]&.each do |r|
|
|
34
|
+
value = r["value"]
|
|
35
|
+
next if turbo_sentinel?(value)
|
|
36
|
+
|
|
37
|
+
logger.puts(value)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
TURBO_SENTINEL_PREFIX = "__lightpanda_turbo_"
|
|
43
|
+
private_constant :TURBO_SENTINEL_PREFIX
|
|
44
|
+
|
|
45
|
+
# The Turbo activity tracker signals busy/idle via console.debug
|
|
46
|
+
# sentinels (see subscribe_to_turbo_signals); every consoleAPICalled
|
|
47
|
+
# consumer must filter them out of user-facing output.
|
|
48
|
+
def turbo_sentinel?(value)
|
|
49
|
+
value.is_a?(String) && value.start_with?(TURBO_SENTINEL_PREFIX)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Oldest entries are dropped past this cap so a chatty page can't grow
|
|
53
|
+
# the buffer unbounded across a long session.
|
|
54
|
+
CONSOLE_LOGS_LIMIT = 1_000
|
|
55
|
+
|
|
56
|
+
# Ring-buffer every console.* call for `Browser#console_logs`. Separate
|
|
57
|
+
# from subscribe_to_console_logs (which streams to an optional IO logger)
|
|
58
|
+
# so capture works without any logger configured. Skips the Turbo
|
|
59
|
+
# activity-tracker sentinels — they're driver plumbing, not page output.
|
|
60
|
+
def subscribe_to_console_capture
|
|
61
|
+
on("Runtime.consoleAPICalled") do |params|
|
|
62
|
+
args = params["args"]
|
|
63
|
+
next unless args.is_a?(Array)
|
|
64
|
+
|
|
65
|
+
first = args.first&.dig("value")
|
|
66
|
+
next if turbo_sentinel?(first)
|
|
67
|
+
|
|
68
|
+
entry = {
|
|
69
|
+
type: params["type"],
|
|
70
|
+
text: args.map { |a| a.fetch("value") { a["description"] }.to_s }.join(" "),
|
|
71
|
+
timestamp: params["timestamp"],
|
|
72
|
+
args: args,
|
|
73
|
+
}
|
|
74
|
+
@console_logs_mutex.synchronize do
|
|
75
|
+
@console_logs << entry
|
|
76
|
+
@console_logs.shift(@console_logs.size - CONSOLE_LOGS_LIMIT) if @console_logs.size > CONSOLE_LOGS_LIMIT
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Wire @turbo_event to the JS-side _signalTurbo emissions. The JS calls
|
|
82
|
+
# console.debug('__lightpanda_turbo_busy') / '_idle' on transitions across
|
|
83
|
+
# zero pending ops; Lightpanda forwards those to Runtime.consoleAPICalled.
|
|
84
|
+
# Idle → set the event (wakes any waiter); busy → reset.
|
|
85
|
+
#
|
|
86
|
+
# On Runtime.executionContextsCleared (navigation), unconditionally set
|
|
87
|
+
# the event: if we navigated away mid-busy state, no further idle signal
|
|
88
|
+
# would ever come from the old context, and we'd block for the full
|
|
89
|
+
# timeout. The new context will signal busy again if Turbo is active.
|
|
90
|
+
def subscribe_to_turbo_signals
|
|
91
|
+
on("Runtime.consoleAPICalled") do |params|
|
|
92
|
+
next unless params["args"].is_a?(Array)
|
|
93
|
+
|
|
94
|
+
marker = params["args"].first&.dig("value")
|
|
95
|
+
next unless turbo_sentinel?(marker)
|
|
96
|
+
|
|
97
|
+
case marker
|
|
98
|
+
when "#{TURBO_SENTINEL_PREFIX}busy" then @turbo_event.reset
|
|
99
|
+
when "#{TURBO_SENTINEL_PREFIX}idle" then @turbo_event.set
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
on("Runtime.executionContextsCleared") { @turbo_event.set }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capybara
|
|
4
|
+
module Lightpanda
|
|
5
|
+
class Browser
|
|
6
|
+
# Element finding in the three dispatch contexts (document, node-
|
|
7
|
+
# scoped, iframe) plus the shared XPath/CSS find fragments and
|
|
8
|
+
# InvalidSelector translation.
|
|
9
|
+
module Finder
|
|
10
|
+
# Find elements in the current context (top frame or active frame).
|
|
11
|
+
# Returns an array of remote object ID strings.
|
|
12
|
+
def find(method, selector)
|
|
13
|
+
if @frame_stack.empty?
|
|
14
|
+
find_in_document(method, selector)
|
|
15
|
+
else
|
|
16
|
+
find_in_frame(method, selector)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Find child elements within a specific node.
|
|
21
|
+
# Returns an array of remote object ID strings.
|
|
22
|
+
#
|
|
23
|
+
# Wrapped in `with_default_context_wait` so a click that triggered a
|
|
24
|
+
# navigation immediately before the find (e.g. a fill_in following a
|
|
25
|
+
# link that mutated the DOM) doesn't race against
|
|
26
|
+
# `Runtime.executionContextCreated` and surface as
|
|
27
|
+
# `NoExecutionContextError`. `find_in_document` and `find_in_frame`
|
|
28
|
+
# already use the same wrapper; `find_within` was the odd one out.
|
|
29
|
+
def find_within(remote_object_id, method, selector)
|
|
30
|
+
with_default_context_wait do
|
|
31
|
+
result = call_function_on(remote_object_id, FIND_WITHIN_JS, method, selector, return_by_value: false)
|
|
32
|
+
extract_node_object_ids(result)
|
|
33
|
+
end
|
|
34
|
+
rescue JavaScriptError => e
|
|
35
|
+
raise_invalid_selector(e, method, selector)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Ancestor chain of `remote_object_id` from parentNode up to (but
|
|
39
|
+
# excluding) `document`, returned as an array of remote object IDs.
|
|
40
|
+
# Mirrors Cuprite's JS `parents` helper. Same `with_default_context_wait`
|
|
41
|
+
# wrapping as `find_within` — same race window applies.
|
|
42
|
+
def parents_of(remote_object_id)
|
|
43
|
+
with_default_context_wait do
|
|
44
|
+
result = call_function_on(remote_object_id, PARENTS_JS, return_by_value: false)
|
|
45
|
+
extract_node_object_ids(result)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Sentinel string thrown from FIND_*_JS when querySelectorAll rejects a
|
|
52
|
+
# malformed selector, so the Ruby side can convert JavaScriptError into
|
|
53
|
+
# Capybara::Lightpanda::InvalidSelector. Cuprite uses a JS subclass for
|
|
54
|
+
# the same purpose; a plain prefixed string keeps our inline JS simple.
|
|
55
|
+
INVALID_SELECTOR_MARKER = "LIGHTPANDA_INVALID_SELECTOR:"
|
|
56
|
+
|
|
57
|
+
# The find algorithms exist in three dispatch contexts — element-scoped
|
|
58
|
+
# (FIND_WITHIN_JS), iframe-scoped (FIND_IN_FRAME_JS), and document-scoped
|
|
59
|
+
# (find_in_document's Runtime.evaluate fast path) — that differ only in
|
|
60
|
+
# how the document/root/selector expressions are derived. Each algorithm
|
|
61
|
+
# is defined ONCE here and instantiated per context via format(), so a
|
|
62
|
+
# fix (e.g. a new XPath error case) can't silently miss a copy.
|
|
63
|
+
#
|
|
64
|
+
# XPath routes through native `Document.evaluate` + `XPathResult`
|
|
65
|
+
# (Lightpanda PR #2305, in nightly >=6109); on parse error we return
|
|
66
|
+
# [] silently to match Capybara's internal XPath generator, which
|
|
67
|
+
# sometimes produces selectors with empty trailing predicates like
|
|
68
|
+
# `(...)[]` that native rejects but `has_element?` expects to behave
|
|
69
|
+
# as "not found" rather than raise InvalidSelector.
|
|
70
|
+
# `XPathResult.ORDERED_NODE_SNAPSHOT_TYPE` is `7` in the spec — inlined
|
|
71
|
+
# so the JS doesn't depend on the enum being defined as a constant.
|
|
72
|
+
XPATH_FIND_FRAGMENT = <<~JS
|
|
73
|
+
try {
|
|
74
|
+
var r = %<doc>s.evaluate(%<selector>s, %<root>s, null, 7, null);
|
|
75
|
+
var nodes = [];
|
|
76
|
+
for (var i = 0; i < r.snapshotLength; i++) nodes.push(r.snapshotItem(i));
|
|
77
|
+
return nodes;
|
|
78
|
+
} catch(e) { return []; }
|
|
79
|
+
JS
|
|
80
|
+
|
|
81
|
+
# For CSS, any throw from querySelectorAll means the selector is
|
|
82
|
+
# malformed — re-throw with the marker prefix so Ruby converts to
|
|
83
|
+
# InvalidSelector.
|
|
84
|
+
CSS_FIND_FRAGMENT = <<~JS.freeze
|
|
85
|
+
try { return Array.from(%<target>s.querySelectorAll(%<selector>s)); }
|
|
86
|
+
catch(e) { throw new Error('#{INVALID_SELECTOR_MARKER}' + %<selector>s); }
|
|
87
|
+
JS
|
|
88
|
+
private_constant :XPATH_FIND_FRAGMENT, :CSS_FIND_FRAGMENT
|
|
89
|
+
|
|
90
|
+
# JS function for finding elements within a node.
|
|
91
|
+
# Works in any execution context (top frame or iframe).
|
|
92
|
+
FIND_WITHIN_JS = <<~JS.freeze
|
|
93
|
+
function(method, selector) {
|
|
94
|
+
if (method === 'xpath') {
|
|
95
|
+
#{format(XPATH_FIND_FRAGMENT, doc: 'this.ownerDocument', root: 'this', selector: 'selector')}
|
|
96
|
+
}
|
|
97
|
+
#{format(CSS_FIND_FRAGMENT, target: 'this', selector: 'selector')}
|
|
98
|
+
}
|
|
99
|
+
JS
|
|
100
|
+
private_constant :FIND_WITHIN_JS
|
|
101
|
+
|
|
102
|
+
# JS function for finding elements in an iframe's contentDocument.
|
|
103
|
+
FIND_IN_FRAME_JS = <<~JS.freeze
|
|
104
|
+
function(method, selector) {
|
|
105
|
+
var doc;
|
|
106
|
+
try { doc = this.contentDocument || (this.contentWindow && this.contentWindow.document); } catch(e) {}
|
|
107
|
+
if (!doc) return [];
|
|
108
|
+
if (method === 'xpath') {
|
|
109
|
+
#{format(XPATH_FIND_FRAGMENT, doc: 'doc', root: 'doc', selector: 'selector')}
|
|
110
|
+
}
|
|
111
|
+
#{format(CSS_FIND_FRAGMENT, target: 'doc', selector: 'selector')}
|
|
112
|
+
}
|
|
113
|
+
JS
|
|
114
|
+
private_constant :FIND_IN_FRAME_JS
|
|
115
|
+
|
|
116
|
+
# Walks `parentNode` from `this` up to (but excluding) `document`,
|
|
117
|
+
# returning the chain as a JS array. Each entry is an element node so
|
|
118
|
+
# `extract_node_object_ids` can wrap them as Lightpanda::Nodes.
|
|
119
|
+
PARENTS_JS = <<~JS
|
|
120
|
+
function() {
|
|
121
|
+
var nodes = [];
|
|
122
|
+
var p = this.parentNode;
|
|
123
|
+
while (p && p !== this.ownerDocument) {
|
|
124
|
+
nodes.push(p);
|
|
125
|
+
p = p.parentNode;
|
|
126
|
+
}
|
|
127
|
+
return nodes;
|
|
128
|
+
}
|
|
129
|
+
JS
|
|
130
|
+
private_constant :PARENTS_JS
|
|
131
|
+
|
|
132
|
+
def find_in_document(method, selector)
|
|
133
|
+
with_default_context_wait do
|
|
134
|
+
# Coerce Symbol selectors (e.g. Capybara warning path lets `have_css(:p)`
|
|
135
|
+
# through) to a string before quoting. Symbol#inspect returns `:p`,
|
|
136
|
+
# which would inject a bare token into the JS source.
|
|
137
|
+
selector_literal = selector.to_s.inspect
|
|
138
|
+
# Same fragments as FIND_WITHIN_JS/FIND_IN_FRAME_JS, instantiated
|
|
139
|
+
# with the selector embedded as a literal: this hot path keeps its
|
|
140
|
+
# single Runtime.evaluate round-trip (no document-handle resolution).
|
|
141
|
+
fragment = if method == "xpath"
|
|
142
|
+
format(XPATH_FIND_FRAGMENT, doc: "document", root: "document", selector: selector_literal)
|
|
143
|
+
else
|
|
144
|
+
format(CSS_FIND_FRAGMENT, target: "document", selector: selector_literal)
|
|
145
|
+
end
|
|
146
|
+
result = evaluate_with_ref("(function() { #{fragment} })()")
|
|
147
|
+
extract_node_object_ids(result)
|
|
148
|
+
end
|
|
149
|
+
rescue JavaScriptError => e
|
|
150
|
+
raise_invalid_selector(e, method, selector)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def find_in_frame(method, selector)
|
|
154
|
+
with_default_context_wait do
|
|
155
|
+
frame_node = @frame_stack.last
|
|
156
|
+
result = call_function_on(frame_node.remote_object_id, FIND_IN_FRAME_JS, method, selector,
|
|
157
|
+
return_by_value: false)
|
|
158
|
+
extract_node_object_ids(result)
|
|
159
|
+
end
|
|
160
|
+
rescue JavaScriptError => e
|
|
161
|
+
raise_invalid_selector(e, method, selector)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def raise_invalid_selector(js_error, method, selector)
|
|
165
|
+
if js_error.message.include?(INVALID_SELECTOR_MARKER)
|
|
166
|
+
raise InvalidSelector.new("Invalid #{method} selector: #{selector.inspect}", method, selector)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
raise js_error
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Extract individual node objectIds from a remote array reference.
|
|
173
|
+
# `ensure release_object` so the outer array handle is freed even when
|
|
174
|
+
# property walking raises — without this, a transient CDP error during
|
|
175
|
+
# property enumeration leaks one V8 handle per failed find call.
|
|
176
|
+
def extract_node_object_ids(result)
|
|
177
|
+
return [] unless result && result["objectId"]
|
|
178
|
+
|
|
179
|
+
outer_id = result["objectId"]
|
|
180
|
+
begin
|
|
181
|
+
props = get_object_properties(outer_id)
|
|
182
|
+
properties = props["result"] || []
|
|
183
|
+
properties
|
|
184
|
+
.select { |p| p["name"] =~ /\A\d+\z/ }
|
|
185
|
+
.sort_by { |p| p["name"].to_i }
|
|
186
|
+
.filter_map { |p| p.dig("value", "objectId") }
|
|
187
|
+
rescue Error
|
|
188
|
+
[]
|
|
189
|
+
ensure
|
|
190
|
+
release_object(outer_id)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capybara
|
|
4
|
+
module Lightpanda
|
|
5
|
+
class Browser
|
|
6
|
+
# JS dialog handling via Lightpanda's LP.handleJavaScriptDialog
|
|
7
|
+
# pre-arm model (PR #2261): accept/dismiss are sent BEFORE the
|
|
8
|
+
# triggering action; Page.javascriptDialogOpening supplies the text.
|
|
9
|
+
module Modals
|
|
10
|
+
# -- Modal/Dialog Support --
|
|
11
|
+
# Lightpanda's JS dialogs (alert/confirm/prompt) are driven via the
|
|
12
|
+
# `LP.handleJavaScriptDialog` pre-arm model (PR #2261, nightly ≥5900):
|
|
13
|
+
# the client sends `LP.handleJavaScriptDialog {accept, promptText}`
|
|
14
|
+
# BEFORE the action that triggers the dialog, and the response is
|
|
15
|
+
# consumed when the dialog opens. `Page.javascriptDialogOpening` still
|
|
16
|
+
# fires, so we capture the message text for `find_modal`. Single-shot:
|
|
17
|
+
# `pending_dialog_response` is one slot, so a second pre-arm before
|
|
18
|
+
# the first dialog opens overwrites the first.
|
|
19
|
+
|
|
20
|
+
def prepare_modals
|
|
21
|
+
return if @modal_handler_installed
|
|
22
|
+
|
|
23
|
+
enable_page_events
|
|
24
|
+
|
|
25
|
+
on("Page.javascriptDialogOpening") do |params|
|
|
26
|
+
entry = { type: params["type"], message: params["message"] }
|
|
27
|
+
@modal_messages_mutex.synchronize { @modal_messages << entry }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
@modal_handler_installed = true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def accept_modal(_type, text: nil)
|
|
34
|
+
prepare_modals
|
|
35
|
+
params = { accept: true }
|
|
36
|
+
params[:promptText] = text if text
|
|
37
|
+
page_command("LP.handleJavaScriptDialog", **params)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def dismiss_modal(_type)
|
|
41
|
+
prepare_modals
|
|
42
|
+
page_command("LP.handleJavaScriptDialog", accept: false)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# `type` is accepted for the error message only: like Selenium (where
|
|
46
|
+
# alert/confirm are indistinguishable) and Cuprite (whose dialog handler
|
|
47
|
+
# accepts whatever fires), we deliberately do NOT reject a dialog whose
|
|
48
|
+
# reported type differs from the one Capybara asked for. Real suites
|
|
49
|
+
# wrap `data-confirm` deletes in `accept_alert` (e.g. solidus admin) and
|
|
50
|
+
# expect it to work; only the message text is matched.
|
|
51
|
+
def find_modal(type, text: nil, wait: options.timeout)
|
|
52
|
+
regexp = text.is_a?(Regexp) ? text : (text && Regexp.new(Regexp.escape(text.to_s)))
|
|
53
|
+
last_seen_message = nil
|
|
54
|
+
claimed = nil
|
|
55
|
+
Utils::Wait.until(timeout: wait, interval: 0.05) do
|
|
56
|
+
claimed = pop_modal_message(regexp)
|
|
57
|
+
next true if claimed
|
|
58
|
+
|
|
59
|
+
last_seen_message = peek_last_modal_message || last_seen_message
|
|
60
|
+
false
|
|
61
|
+
end
|
|
62
|
+
claimed[:message]
|
|
63
|
+
rescue TimeoutError
|
|
64
|
+
raise_modal_not_found(type, text, last_seen_message)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Pop the first queued dialog whose message matches the requested
|
|
70
|
+
# pattern (any dialog when `regexp` is nil). Returns the entry or nil.
|
|
71
|
+
# Serialized with the message-thread writer.
|
|
72
|
+
def pop_modal_message(regexp)
|
|
73
|
+
@modal_messages_mutex.synchronize do
|
|
74
|
+
match = @modal_messages.find do |m|
|
|
75
|
+
regexp.nil? || m[:message].to_s.match?(regexp)
|
|
76
|
+
end
|
|
77
|
+
@modal_messages.delete(match) if match
|
|
78
|
+
match
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Most recent dialog message of any type, for diagnostics.
|
|
83
|
+
def peek_last_modal_message
|
|
84
|
+
@modal_messages_mutex.synchronize { @modal_messages.last&.dig(:message) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def raise_modal_not_found(type, text, last_seen_message)
|
|
88
|
+
if last_seen_message
|
|
89
|
+
raise Capybara::ModalNotFound,
|
|
90
|
+
"Unable to find #{type} modal with #{text} - found '#{last_seen_message}' instead."
|
|
91
|
+
end
|
|
92
|
+
raise Capybara::ModalNotFound, "Unable to find modal dialog#{" with #{text}" if text}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private :prepare_modals
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|