capybara-lightpanda 0.14.1 → 0.15.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: d38fc6a0a5cbba01bba3a0a1363d8e0577dc99c2cb8a77b81f407b273a91863b
4
- data.tar.gz: 21cdea0aeba27736c52f800c22e850d80ea15d3c7bb1c6c9b173421bb227be7d
3
+ metadata.gz: bcfaa2e5c56f74e8f6068a969ee03d669d11d49de80ae360fb4a62a96833c8f3
4
+ data.tar.gz: 3a22106094a7f2de8c3447f9573286e90a4158f25f88b06701cf4540ab9e2147
5
5
  SHA512:
6
- metadata.gz: ec4a2743650df3602c43fc393a82af00c19c5b44d59fab31156aed0b3252e8fb7f3c5f06df1b6e2fe994c9b974a1b21b29707c22c6bff571d4c2b6aca195add4
7
- data.tar.gz: ba19fbd3c2754eae2b6d3af02584d739eba498b93e0e0ff392f9dc7a9feefdc1af1ff68081cd5b6963ca41c768cf8cebcf3b48b9debd9923e5142b15ac1c9223
6
+ metadata.gz: ebf7c506245fdc550ff5ac3bc643dfe6369c4324642bffe8fb4a27f891f009eaaa842effe0bb7386dfc6d50819ad934d0a3df0e7f67e4fb222116baa5a957955
7
+ data.tar.gz: 2aaef86e3c5a6c1a4e25c3476790352564df15c0b46c18c02745b5999e587a35090ff88837340dec8e00f977a828ec3ce1eaf9a505b10abfb0412e2538cf4d13
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.15.0] - 2026-10-23
4
+
5
+ > **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.
6
+
7
+ ### Added
8
+
9
+ - **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.
10
+
11
+ ### Changed
12
+
13
+ - **`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.
14
+ - **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.
15
+
16
+ ### Fixed
17
+
18
+ - **`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.
19
+ - **`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`.
20
+ - **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.
21
+ - **`send_keys` types numbers, as Selenium does.** `send_keys [1, :tab]` used to drop the `1` silently and send only the Tab.
22
+ - **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.
23
+ - **`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.
24
+ - **Elements inside a shadow root are no longer reported as obscured.** `obscured?` reported every shadow DOM element as covered by its own host.
25
+ - **`trigger(:input)` sends a real `InputEvent`.** Listeners that read `event.inputType` got `undefined`; they now get `"insertText"`, as when typing.
26
+ - **`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 `�`.
27
+ - **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.
28
+
29
+ ## [0.14.2] - 2026-10-14
30
+
31
+ ### Fixed
32
+
33
+ - **A browser that stops reading no longer hangs the test process.** If Lightpanda got stuck (a page in an endless synchronous loop, a stopped process) while a large command was being sent, about 1 MB and up (a big `execute_script` bundle, for example), the send blocked forever: the command's timeout never started, every other command waited behind it, and so did quitting the browser, at exit included. Sending now gives up after the command's timeout and raises `DeadBrowserError`.
34
+ - **Clicking a `mailto:` or `tel:` link keeps the page, as in Chrome.** On Lightpanda 1.0.0, clicking `<a href="mailto:someone@example.com">` left the page and loaded `example.com`, the host in the address. On nightlies 10261 to 10535, clicking a `tel:` link left the page unusable: every later call raised `NoExecutionContextError`. The page now stays and your own click handlers still run. The same applies to any link whose scheme the browser can't load (`sms:`, `ftp:`…).
35
+
3
36
  ## [0.14.1] - 2026-10-12
4
37
 
5
38
  ### 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>&nbsp;&nbsp;→&nbsp;&nbsp;<code>capybara-lightpanda</code>&nbsp;&nbsp;→&nbsp;&nbsp;<a href="https://lightpanda.io/"><img src="docs/static/img/lightpanda-logo.svg" alt="Lightpanda" height="22" valign="middle"></a>&nbsp;<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
  [![Capybara::Lightpanda — faster system tests for Rails, without Chromium](docs/static/img/banner.png)](https://navidemad.github.io/capybara-lightpanda/)
17
- <sub><em>Configuration · dual-driver setups · Turbo Rails · capability matrix · beta-testing guide</em></sub>
17
+ <sub><em>Configuration · setup recipes · Turbo Rails · known limitations · beta-testing guide</em></sub>
18
18
 
19
19
  [![Read the docs](https://img.shields.io/badge/https%3A%2F%2Fnavidemad.github.io%2Fcapybara--lightpanda-→%20Visit%20the%20website-1F2937?style=for-the-badge)](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 — plus the two that catch people out, a second browser tab and a menu revealed purely by CSS `:hover` — should stay on Cuprite (or whichever full-browser driver you were already using). The [per-spec dual-driver setup](https://navidemad.github.io/capybara-lightpanda/docs/#dual-per-spec) routes that minority to Cuprite and the structural majority to Lightpanda for speed.
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.0"` (the tagged release the gem's current floor is pinned to — nightly build 9994 is that release's commit). Without a pin the driver tracks Lightpanda's rolling `nightly` tag, which moves under you. See [Pinning the browser version](https://navidemad.github.io/capybara-lightpanda/docs/#pinning).
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 [Cuprite](https://github.com/rubycdp/cuprite) / [Ferrum](https://github.com/rubycdp/ferrum) architecture and [`lightpanda-ruby`](https://github.com/marcoroth/lightpanda-ruby)
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 and errors.js register their listeners at top level and expose
21
- # nothing through attach.js, so they only need to run inside the IIFE.
22
- PARTS = %w[banner.js turbo.js errors.js predicates.js attach.js].freeze
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
- # Lightpanda emits no Runtime.exceptionThrown at all, so the source is
39
- # a passive listener pair in javascripts/errors.js reporting over the
40
- # console.debug sentinel channel. It sees what reaches `window`:
41
- # exceptions a framework catches itself (Stimulus's handleError, any
42
- # try/catch) never arrive, and a cross-origin script collapses to
43
- # "Script error." with no detail. Partial by construction — but the
44
- # alternative is the zero visibility that made the solidus taxon-tree
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 — missing
104
- # a sentinel here leaks driver plumbing into user-facing output.
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) || page_error_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: the Turbo ones are dropped, and the
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
- # Parse one `__lightpanda_page_error_<json>` sentinel into @page_errors.
146
- # A payload that won't parse is still worth surfacing — dropping it would
147
- # turn "the page threw" into silence, which is the whole failure mode
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, return_by_value: false)
32
- extract_node_object_ids(result)
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
- # Mirrors Cuprite's JS `parents` helper. Same `with_default_context_wait`
41
- # wrapping as `find_within` — same race window applies.
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
- extract_node_object_ids(result)
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. Cuprite uses a JS subclass for
54
- # the same purpose; a plain prefixed string keeps our inline JS simple.
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
- 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')}
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
- if (method === 'xpath') {
109
- #{format(XPATH_FIND_FRAGMENT, doc: 'doc', root: 'doc', selector: 'selector')}
110
- }
111
- #{format(CSS_FIND_FRAGMENT, target: 'doc', selector: 'selector')}
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
- # `extract_node_object_ids` can wrap them as Lightpanda::Nodes.
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
- result = evaluate_with_ref("(function() { #{fragment} })()")
147
- extract_node_object_ids(result)
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
- extract_node_object_ids(result)
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 individual node objectIds from a remote array reference.
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 extract_node_object_ids(result)
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
- .select { |p| p["name"] =~ /\A\d+\z/ }
185
- .sort_by { |p| p["name"].to_i }
186
- .filter_map { |p| p.dig("value", "objectId") }
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` (Cuprite's option name). Either way
61
- # the dialog is already gone — Lightpanda's default resolved it.
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 Cuprite (whose dialog handler
78
- # accepts whatever fires), we deliberately do NOT reject a dialog whose
79
- # reported type differs from the one Capybara asked for. Real suites
80
- # wrap `data-confirm` deletes in `accept_alert` (e.g. solidus admin) and
81
- # expect it to work; only the message text is matched.
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. Through 1.0.0 every
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 (build 10255) `window` lands here and raised. Both it
277
- # and a plain self-referencing object now return CyclicObject.
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 (Cuprite parity). nil => downloads stay off.
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. Cuprite waits for the load event. The
691
- # event is awaited briefly, then document.readyState is polled for the
692
- # rest of the budget, as #go_to does (lightpanda-io/browser#1801: page
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