capybara-lightpanda 0.7.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 +48 -0
- data/lib/capybara/lightpanda/auto_scripts.rb +28 -2
- data/lib/capybara/lightpanda/binary.rb +49 -51
- 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 +65 -780
- 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/cookies.rb +2 -0
- data/lib/capybara/lightpanda/driver.rb +44 -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 +75 -13
- data/lib/capybara/lightpanda/node.rb +31 -31
- data/lib/capybara/lightpanda/options.rb +4 -0
- data/lib/capybara/lightpanda/process.rb +13 -22
- data/lib/capybara/lightpanda/railtie.rb +15 -0
- data/lib/capybara/lightpanda/version.rb +1 -1
- data/lib/capybara-lightpanda.rb +1 -0
- metadata +11 -2
- data/lib/capybara/lightpanda/javascripts/index.js +0 -239
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,53 @@
|
|
|
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
|
+
|
|
28
|
+
## [0.8.0] - 2026-06-12
|
|
29
|
+
|
|
30
|
+
> **Update Lightpanda before upgrading.** Requires a nightly build ≥ 6736 (published 2026-06-12). The driver refuses to start against older binaries.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- `lightpanda:binary:*` rake tasks now load automatically inside Rails apps (via a Railtie), and the "Lightpanda is too old" error suggests a require-the-gem one-liner instead of those rake tasks. The old hint was a dead end: the tasks weren't loaded in Rails apps at all, and even with the Railtie they only exist under `RAILS_ENV=test` when the gem sits in the `:test` Gemfile group. (Found beta-testing a private Rails suite.)
|
|
35
|
+
- `Driver#render` as an alias of `save_screenshot`. capybara-screenshot calls `driver.render(path)` for drivers it doesn't know, so every failing test in a capybara-screenshot suite logged "Screenshot could not be saved: undefined method 'render'" — once per failure.
|
|
36
|
+
- `Driver#headers=` / `#add_headers` / `#headers`, delegating to the existing `Network` support. Cuprite exposes header writers on the driver and real suites call them there (`page.driver.headers = …`).
|
|
37
|
+
- `Cookies#[]` as an alias of `#get` — the Ferrum/Cuprite spelling (`browser.cookies["session_id"]`).
|
|
38
|
+
- `Browser#console_logs` / `#clear_console_logs` — console messages captured since the last session reset, as `{type:, text:, timestamp:, args:}` hashes (driver-internal Turbo sentinels excluded, buffer capped at 1,000 entries). Suites that assert "no JS errors leaked" no longer need to wire a custom Ferrum-style logger: `page.driver.browser.console_logs.select { |m| m[:type] == "error" }`. Note that Lightpanda currently reports both `console.log` and `console.warn` as type `"info"` — filter on `text` when you need to distinguish them.
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- Modal assertions match dialogs by message text regardless of the alert/confirm/prompt type, like Selenium and Cuprite — `accept_alert` around a `data-confirm` delete button now works.
|
|
43
|
+
- Clicks dispatch the full pointer sequence (mousedown → mouseup → click). Widgets that open on mousedown — select2 v3 dropdowns, for example — now react to `click` / `select_from`.
|
|
44
|
+
- The driver no longer re-dispatches `readystatechange` itself. Lightpanda fires the event natively as of nightly 6736 (lightpanda-io/browser#2708), so the shim added in 0.7.0 became redundant and was removed. Behavior is unchanged for Turbo/Hotwire apps — `turbo:load` still fires on every visit, now from the browser's own event.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- A failed binary download that falls back to a stale cached binary now warns loudly on stderr (with the original error and a re-provision one-liner) instead of logging only under `LIGHTPANDA_DEBUG`. VCR-guarded suites hit this path silently — VCR's blocking error is a `StandardError`, unlike raw WebMock's — and the only visible symptom was a confusing "Lightpanda is too old" error much later.
|
|
49
|
+
- Turbo Streams over ActionCable now connect. Page-initiated WebSocket upgrades carry the document's `Origin` header as of nightly 6736 (lightpanda-io/browser#2710, guaranteed by the new minimum build), so ActionCable's request-forgery protection accepts the connection instead of rejecting it with `Request origin not allowed: nil`. `turbo-cable-stream-source` elements reach `[connected]` and `turbo_stream_for` broadcasts (solid_cable or any adapter) arrive in specs. If you had added `config.action_cable.disable_request_forgery_protection = true` to your test environment to work around this, you can remove it.
|
|
50
|
+
|
|
3
51
|
## [0.7.0] - 2026-06-12
|
|
4
52
|
|
|
5
53
|
### Changed
|
|
@@ -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 = {
|
|
@@ -33,6 +19,13 @@ module Capybara
|
|
|
33
19
|
|
|
34
20
|
DEFAULT_CACHE_TIME = 86_400
|
|
35
21
|
|
|
22
|
+
# One-liner that re-provisions the binary from a process with no
|
|
23
|
+
# HTTP-stubbing loaded (VCR/WebMock guard the test process itself).
|
|
24
|
+
# Referenced from the stale-fallback warning and BETA_TESTING.md.
|
|
25
|
+
PROVISION_HINT =
|
|
26
|
+
"bundle exec ruby -r capybara-lightpanda " \
|
|
27
|
+
"-e 'Capybara::Lightpanda::Binary.remove; puts Capybara::Lightpanda::Binary.update'"
|
|
28
|
+
|
|
36
29
|
class << self
|
|
37
30
|
# Set a specific release tag (e.g. "0.3.0") to pin downloads to that
|
|
38
31
|
# release. When nil, the rolling "nightly" tag is used. The pin only
|
|
@@ -64,10 +57,6 @@ module Capybara
|
|
|
64
57
|
yield self
|
|
65
58
|
end
|
|
66
59
|
|
|
67
|
-
def path
|
|
68
|
-
@path ||= update
|
|
69
|
-
end
|
|
70
|
-
|
|
71
60
|
# Canonical entrypoint: ensure the binary at install_path is current,
|
|
72
61
|
# download if needed, return its path. Pinned (required_version set)
|
|
73
62
|
# never re-downloads when present. Unpinned re-downloads when older
|
|
@@ -96,7 +85,36 @@ module Capybara
|
|
|
96
85
|
return system_path
|
|
97
86
|
end
|
|
98
87
|
|
|
99
|
-
|
|
88
|
+
# Stale-or-absent cache, nothing on PATH: refresh from the network.
|
|
89
|
+
# If that fails (GitHub 5xx, DNS/connect timeouts, SocketError) but a
|
|
90
|
+
# usable — if stale — binary is already cached, keep using it rather
|
|
91
|
+
# than hard-failing. A cold cache (nothing on disk) still surfaces the
|
|
92
|
+
# error. The MINIMUM_NIGHTLY_BUILD floor is enforced downstream in
|
|
93
|
+
# Process#start, so a sub-floor binary can't slip in.
|
|
94
|
+
#
|
|
95
|
+
# Deliberately StandardError, not Exception: WebMock's
|
|
96
|
+
# NetConnectNotAllowedError descends from Exception so it propagates
|
|
97
|
+
# through app rescue blocks by design — a test suite that blocks net
|
|
98
|
+
# connections SHOULD fail loudly here, not silently fall back. CI
|
|
99
|
+
# pre-provisions the binary outside that guard instead (real-apps.yml).
|
|
100
|
+
begin
|
|
101
|
+
download
|
|
102
|
+
rescue StandardError => e
|
|
103
|
+
raise unless File.executable?(destination)
|
|
104
|
+
|
|
105
|
+
# Kernel.warn, not log: log() is silent unless LIGHTPANDA_DEBUG or
|
|
106
|
+
# an explicit logger is set, and this fallback is exactly the
|
|
107
|
+
# moment the user needs to hear about — a VCR-guarded suite (whose
|
|
108
|
+
# UnhandledHTTPRequestError is a StandardError, unlike raw
|
|
109
|
+
# WebMock's Exception) lands here silently, keeps a stale binary,
|
|
110
|
+
# and later hits a confusing MINIMUM_NIGHTLY_BUILD floor error
|
|
111
|
+
# with no trace of the blocked download.
|
|
112
|
+
warn("[capybara-lightpanda] Binary download failed (#{e.class}: #{e.message}); " \
|
|
113
|
+
"falling back to the cached binary at #{destination}. " \
|
|
114
|
+
"If your suite stubs HTTP (VCR/WebMock), pre-provision from an " \
|
|
115
|
+
"unstubbed process: #{PROVISION_HINT}")
|
|
116
|
+
destination
|
|
117
|
+
end
|
|
100
118
|
end
|
|
101
119
|
|
|
102
120
|
# Delete the cached binary. Returns the path that was deleted, or nil
|
|
@@ -109,7 +127,6 @@ module Capybara
|
|
|
109
127
|
end
|
|
110
128
|
|
|
111
129
|
File.delete(path)
|
|
112
|
-
@path = nil
|
|
113
130
|
log("Removed #{path}")
|
|
114
131
|
path
|
|
115
132
|
end
|
|
@@ -126,30 +143,6 @@ module Capybara
|
|
|
126
143
|
nil
|
|
127
144
|
end
|
|
128
145
|
|
|
129
|
-
def run(*)
|
|
130
|
-
stdout, stderr, status = Open3.capture3(path, *)
|
|
131
|
-
|
|
132
|
-
Result.new(stdout: stdout, stderr: stderr, status: status)
|
|
133
|
-
rescue Errno::ENOENT
|
|
134
|
-
raise BinaryNotFoundError, "Lightpanda binary not found"
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def exec(*)
|
|
138
|
-
Kernel.exec(path, *)
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
def fetch(url)
|
|
142
|
-
result = run("fetch", "--dump", url)
|
|
143
|
-
raise BinaryError, result.stderr unless result.success?
|
|
144
|
-
|
|
145
|
-
result.stdout
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
def version
|
|
149
|
-
result = run("version")
|
|
150
|
-
result.output.strip
|
|
151
|
-
end
|
|
152
|
-
|
|
153
146
|
def download
|
|
154
147
|
binary_name = platform_binary
|
|
155
148
|
tag = required_version || "nightly"
|
|
@@ -161,7 +154,6 @@ module Capybara
|
|
|
161
154
|
|
|
162
155
|
download_file(url, destination)
|
|
163
156
|
FileUtils.chmod(0o755, destination)
|
|
164
|
-
@path = destination
|
|
165
157
|
|
|
166
158
|
destination
|
|
167
159
|
end
|
|
@@ -173,9 +165,15 @@ module Capybara
|
|
|
173
165
|
# suggest `brew update && brew upgrade lightpanda` (brew pins
|
|
174
166
|
# each user's binary at install time and doesn't refresh on its
|
|
175
167
|
# own when the tap publishes a newer nightly).
|
|
176
|
-
# - Path equals our own cache → suggest the gem
|
|
177
|
-
#
|
|
178
|
-
#
|
|
168
|
+
# - Path equals our own cache → suggest the require-the-gem one-liner.
|
|
169
|
+
# NOT the lightpanda:binary:* rake tasks: in a Rails app the gem
|
|
170
|
+
# usually sits in the :test Gemfile group, so the tasks only exist
|
|
171
|
+
# under RAILS_ENV=test (the Railtie can't help a plain `bundle exec
|
|
172
|
+
# rake` in development), and outside Rails they're never loaded at
|
|
173
|
+
# all. The one-liner requires the gem explicitly, so it works from
|
|
174
|
+
# any environment. The `remove` step is required because `update`
|
|
175
|
+
# honors `cache_time` and would otherwise no-op on a
|
|
176
|
+
# too-old-but-not-yet-expired file.
|
|
179
177
|
# - Anything else (user-managed install at a custom path) → keep
|
|
180
178
|
# the curl-overwrite suggestion, since we don't know how the file
|
|
181
179
|
# got there.
|
|
@@ -183,7 +181,7 @@ module Capybara
|
|
|
183
181
|
if brew_managed?(binary_path)
|
|
184
182
|
"brew update && brew upgrade lightpanda"
|
|
185
183
|
elsif binary_path == install_path
|
|
186
|
-
|
|
184
|
+
PROVISION_HINT
|
|
187
185
|
else
|
|
188
186
|
"curl -sL #{GITHUB_RELEASE_URL}/nightly/#{platform_binary} " \
|
|
189
187
|
"-o #{binary_path} && chmod +x #{binary_path}"
|
|
@@ -272,7 +270,7 @@ module Capybara
|
|
|
272
270
|
end
|
|
273
271
|
|
|
274
272
|
def follow_redirects(uri, destination, limit = 10)
|
|
275
|
-
raise
|
|
273
|
+
raise BinaryError, "Too many redirects" if limit.zero?
|
|
276
274
|
|
|
277
275
|
http_start(uri) do |http|
|
|
278
276
|
request = Net::HTTP::Get.new(uri)
|
|
@@ -287,7 +285,7 @@ module Capybara
|
|
|
287
285
|
log("Redirected → #{response['location']}")
|
|
288
286
|
follow_redirects(URI.parse(response["location"]), destination, limit - 1)
|
|
289
287
|
else
|
|
290
|
-
raise
|
|
288
|
+
raise BinaryError, "Failed to download binary: #{response.code} #{response.message}"
|
|
291
289
|
end
|
|
292
290
|
end
|
|
293
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
|