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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d1c82db6d6a5d5662098021d24688d5ff16592b1cb6de95c2c422307487d91d0
4
- data.tar.gz: 0f21620cae5dc33e640712565a64071a47d78e906520770bef8e2aa593b230ca
3
+ metadata.gz: a7ec772a2b4254837394ec6705d94c8759e750f0f2ba7ce12ac4d02bfdd30bf5
4
+ data.tar.gz: a65953b578a45f0f652f0f3dadc2334bfa6db4ae6c6f4d931da4d9730f3656a1
5
5
  SHA512:
6
- metadata.gz: 27f0ce27958fdbb057b68b684429222cae534d0b32f06521608862a6407a5ceb89935cbfeb4ebfee7de70d7d7d78a92a6a36d547ffdae27171bef1a731606728
7
- data.tar.gz: 492a709cf1117444126ac9429586dc861af46e8e52d779106478e7d96d4c3aff13c1756f9c6ece4d96f41610e4f79f3b7861d02d11eaf294cbc22594c5d28001
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
- JS_PATH = File.expand_path("javascripts/index.js", __dir__).freeze
7
- JS = File.read(JS_PATH).freeze
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
- download
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's rake tasks; the
177
- # `remove` step is required because `update` honors `cache_time`
178
- # and would otherwise no-op on a too-old-but-not-yet-expired file.
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
- "bundle exec rake lightpanda:binary:remove lightpanda:binary:update"
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 BinaryNotFoundError, "Too many redirects" if limit.zero?
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 BinaryNotFoundError, "Failed to download binary: #{response.code} #{response.message}"
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