capybara-lightpanda 0.12.0 → 0.14.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: 2515195575d1146f7cd14f479801360770c65d39d84c2bdc58be987e503bda88
4
- data.tar.gz: ab94aba998ccafb870e109504e7f33dd22a35bcd2ba79750cfd2bb79226feaf1
3
+ metadata.gz: 759abeb96b5d6898d9458d2e81af39b97d155766cfd2c80fa1ed363409a6aff6
4
+ data.tar.gz: 66d54d836501ab8ec7d3cc23ffc389f58777c938a19490f9dc81a076819b4489
5
5
  SHA512:
6
- metadata.gz: 3eadd479f7c543354a89c3f69a1be6ee94be6067b52171766c3da1b3c9b091a9e404c987e9af11c5230c89e8df319013822748b0bd6ba0e7e56ebd143f3e7689
7
- data.tar.gz: fec1bbad25a59b177b6ac117e82bf65eef995196f233aa96742f71b2def90133132b913c0591bf40f2ca554d09cb4e6b26cbb132a0f3ae5e7ab811efa4504914
6
+ metadata.gz: ffdc46e2b18c6ce822fb7765cc668bfda6594e1ce27f9ad88b29e4d9a34f6882b80aad54fcb0581d460e68b3fbcaaaa853017f825295b1a8758a244da520d12d
7
+ data.tar.gz: 6ae51661f2a7108ec85bebf773965dd792f74d04b80cdff7c5992b41be5c32f15b3d7e08d97a64212a64a812be4ebe811f222ad6fa91866888d1f6e50a805b71
data/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.14.0] - 2026-10-11
4
+
5
+ ### Fixed
6
+
7
+ - **`evaluate_script` returns `Capybara::Lightpanda::CyclicObject` for a result that references itself, as Cuprite returns `Ferrum::CyclicObject`.** A self-referencing object used to raise `BrowserError: Object reference chain is too long`. So did `evaluate_script("window")` on recent Lightpanda nightlies, and on older builds it came back as a broken element.
8
+ - **`Cookies#load` keeps restoring a file when one cookie is malformed.** Recent Lightpanda nightlies reject a cookie with a `;` or control character in its name or value, as Chrome does, where older builds stored it. A file saved from an older build could hold such a cookie, and `load` stopped at it with a `BrowserError`. It now skips that cookie and names it in its warning, like any other cookie the browser refuses. `Cookies#set` still raises for it.
9
+ - **Clicking a `javascript:` link no longer breaks the page on recent Lightpanda nightlies.** On nightly 10261 and later, clicking `<a href="javascript:void(0)">` (select2, Bootstrap toggles, older jQuery code) left the page unusable: every later `find` or `evaluate_script` raised `NoExecutionContextError` until the next `visit`. The link's code still runs, once, and the page stays.
10
+ - **A click waits for the page it navigates to, even when the server is slow.** `click` used to return after about 50 ms, before a link, `window.location` assignment or form submission had loaded the next page whenever the server took longer than that (a slow action, a redirect). The very next `current_url` or `assert_current_path(..., wait: 0)` then read the page being left. The click now waits for the new page, as in Cuprite. Clicks that don't navigate are as fast as before, and so are `pushState` and anchor clicks. A navigation that fails, such as a refused connection, doesn't make the click wait for the timeout. `send_keys` waits the same way when Enter submits a form.
11
+ - **`go_back` and `go_forward` keep the page when the history entry comes from `pushState`.** They used to reload the document from the server, so the page's JavaScript state was lost and no `popstate` event fired: a "browser back button" test on a step-by-step page could not pass. They now behave like the browser's buttons: the page stays, `popstate` fires and `current_url` is up to date when the call returns. Going back or forward to another page still loads it, and the call waits for it. Lightpanda limit: on Lightpanda 1.0.0 this only applies when the two entries differ by their `#fragment`. When `pushState` changed the path (Turbo Drive, client-side routers), 1.0.0 still reloads the page ([lightpanda-io/browser#3726](https://github.com/lightpanda-io/browser/issues/3726)); nightly builds from 10239 keep it.
12
+ - **Clicking a link inside an iframe navigates the iframe, not the whole page.** Inside `within_frame`, `click_link` used to replace the whole page with the link's target, and a `#fragment` link in a frame did the same. Links with `target="_top"` or `target="_parent"` still leave the frame, as in Chrome.
13
+ - **Scripts run inside the frame within `within_frame`.** `execute_script`, `evaluate_script` and `evaluate_async_script` used to run against the page around the frame, so `document` and `window` were the top page's. They now see the frame's document, as in Chrome.
14
+ - **An element found in an iframe goes stale when that iframe navigates.** It used to keep answering from the old document: empty text, not visible, clicks that did nothing. Capybara never found it again. It is now treated as obsolete, as in Chrome, and Capybara's automatic reload finds the element in the new page.
15
+ - **`select` works in a form that has a field named `disabled`.** On recent Lightpanda nightlies, an `<input name="disabled">` made every option in its form read as disabled, so `select` silently did nothing.
16
+
17
+ ## [0.13.0] - 2026-10-03
18
+
19
+ > **Update Lightpanda before upgrading.** This release requires Lightpanda **1.0.0** or a nightly build ≥ 9994. The driver refuses anything older and names the version it found. Tagged releases work again: pin `1.0.0` for a reproducible browser.
20
+
21
+ ### Changed
22
+
23
+ - **Cross-origin requests follow CORS, as in Chrome.** Lightpanda 1.0.0 enforces CORS by default, and the driver doesn't turn it off, so a `fetch` or XHR your app sends from `127.0.0.1` to `localhost` (or to another port) is blocked there just as Chrome would block it. Suites already green on Cuprite or Selenium are unaffected. A blocked request is listed in `network.traffic` with an `:error` and no response, and it no longer stalls `wait_for_network_idle` (see Fixed).
24
+ - **`Cookies#set` returns whether the browser accepted the cookie.** Lightpanda now refuses `SameSite=None` without `Secure`. `set` returns `false` in that case instead of dropping the cookie silently. `Cookies#load` repairs cookie files saved by older versions, which marked every cookie `SameSite=None`, and warns about any cookie it still can't restore.
25
+
26
+ ### Fixed
27
+
28
+ - **Clicking a field focuses it again.** On current Lightpanda builds, `click` followed by `send_keys` sent the keys to the page body instead of the clicked field. Clicking a field, a label or a contenteditable region now focuses it, and clicking empty space blurs the focused element, as in Chrome. Capybara's `active_element` specs now pass.
29
+ - **`send_keys` appends to a field's existing value.** On a field that was pre-filled and not yet focused, `send_keys("x")` produced `"xfoo"` instead of `"foox"`. Typing now starts at the end of the value, as in chromedriver. If you need to type at a specific position, focus the field and place the caret yourself first.
30
+ - **Backspace, Delete, Tab, Escape and the arrow keys reach the page.** Previously only keys that type a character were delivered, so these keys did nothing. They now edit fields, move the caret and extend the selection with Shift. A `beforeinput` handler can cancel an edit, which input-mask libraries rely on.
31
+ - **Ctrl/Cmd shortcuts no longer type a character.** `send_keys([:ctrl, "a"])` used to insert a stray `a`. Your shortcut handlers still receive the key.
32
+ - **`select` never picks a disabled option, and picks the right one when values repeat.** When several options shared the same value, the first one was always selected.
33
+ - **Failed requests no longer stall `wait_for_network_idle`.** A request that failed without a response (blocked by CORS, refused connection) stayed pending until the timeout expired.
34
+ - **`console_logs` shows objects.** `console.log({a: 1})`, arrays and DOM elements used to appear as empty text. They now appear as a short description.
35
+ - **A browser crash no longer takes your test process down with it.** If the connection died unexpectedly, the error could be raised somewhere unrelated in your test, or abort a server before its cleanup ran. Commands now fail cleanly with `DeadBrowserError`, waiting commands are released immediately, and each crashed browser releases its connection instead of keeping it open until garbage collection.
36
+ - **`matchMedia` listeners fire when you resize the window.** `resize_window_to` now runs the JavaScript breakpoint handlers. Stylesheet `@media` rules still only apply after the next `visit`.
37
+
3
38
  ## [0.12.0] - 2026-08-28
4
39
 
5
40
  > **Update Lightpanda before upgrading.** This release requires a Lightpanda nightly build ≥ 8875, and the rolling `nightly` tag now carries it (8982 as of 2026-08-28), so updating the browser is all it takes. **No tagged Lightpanda release is new enough yet**: 0.3.7 is below the floor, so version pinning stays unavailable until Lightpanda ships its next release.
data/README.md CHANGED
@@ -57,7 +57,7 @@ driven_by :lightpanda
57
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.
58
58
 
59
59
  > [!TIP]
60
- > For reproducible CI, pin the browser: `Capybara::Lightpanda::Binary.required_version = "0.3.8"`. (0.3.8 is the first release that will clear the gem's current floor, nightly build 8875 — until upstream tags it, the driver needs the rolling `nightly` tag.) 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.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).
61
61
 
62
62
  ## Credits
63
63
 
@@ -59,14 +59,27 @@ module Capybara
59
59
 
60
60
  on("Runtime.consoleAPICalled") do |params|
61
61
  params["args"]&.each do |r|
62
- value = r["value"]
63
- next if driver_sentinel?(value)
62
+ next if driver_sentinel?(r["value"])
64
63
 
65
- logger.puts(value)
64
+ logger.puts(console_arg_text(r))
66
65
  end
67
66
  end
68
67
  end
69
68
 
69
+ # One console argument as text. Since upstream #3504 (build 9414)
70
+ # Lightpanda sends `value` for primitives only — an object, array or
71
+ # DOM node arrives as a RemoteObject with a `description` (sometimes
72
+ # empty), a `className` and a `type`. Reading `value` alone printed
73
+ # objects as blank lines in the logger and DOM nodes as "" in
74
+ # console_logs; fall through to the first non-empty descriptor,
75
+ # the way DevTools labels an object it can't preview.
76
+ def console_arg_text(arg)
77
+ return arg["value"].to_s if arg.key?("value")
78
+
79
+ [arg["unserializableValue"], arg["description"], arg["className"], arg["type"]]
80
+ .find { |v| v.is_a?(String) && !v.empty? }.to_s
81
+ end
82
+
70
83
  TURBO_SENTINEL_PREFIX = "__lightpanda_turbo_"
71
84
  private_constant :TURBO_SENTINEL_PREFIX
72
85
 
@@ -117,7 +130,7 @@ module Capybara
117
130
 
118
131
  entry = {
119
132
  type: params["type"],
120
- text: args.map { |a| a.fetch("value") { a["description"] }.to_s }.join(" "),
133
+ text: args.map { |a| console_arg_text(a) }.join(" "),
121
134
  timestamp: params["timestamp"],
122
135
  args: args,
123
136
  }
@@ -33,12 +33,20 @@ module Capybara
33
33
  end
34
34
  alias goto go_to
35
35
 
36
+ # Back/forward go through the page's own History API, like the
37
+ # browser's buttons: an entry created by `pushState` is restored in
38
+ # the same document (JS state kept, `popstate` fired synchronously),
39
+ # while an entry from another document starts a real navigation that
40
+ # `wait_for_idle` sniffs and waits out. CDP's
41
+ # `Page.navigateToHistoryEntry` is not an option — Lightpanda reloads
42
+ # the document even for a `pushState` entry. Both are no-ops at either
43
+ # end of the session history.
36
44
  def back
37
- wait_for_navigation { navigate_history(-1) }
45
+ traverse_history("history.back()")
38
46
  end
39
47
 
40
48
  def forward
41
- wait_for_navigation { navigate_history(+1) }
49
+ traverse_history("history.forward()")
42
50
  end
43
51
 
44
52
  def refresh
@@ -98,23 +106,13 @@ module Capybara
98
106
  await_navigation(&)
99
107
  end
100
108
 
101
- # Step the session history by `offset` (-1 = back, +1 = forward) using
102
- # native CDP. `Page.getNavigationHistory` returns the entry list and
103
- # `currentIndex`; `Page.navigateToHistoryEntry` jumps to the chosen
104
- # entry's `id`. No-op when the offset would step past either end so
105
- # the behavior matches `history.back()` / `history.forward()` on a
106
- # bounded session history.
107
- def navigate_history(offset)
108
- history = page_command("Page.getNavigationHistory")
109
- target_index = history["currentIndex"] + offset
110
- entries = history["entries"]
111
- return if target_index.negative? || target_index >= entries.length
112
-
113
- page_command("Page.navigateToHistoryEntry", entryId: entries[target_index]["id"])
109
+ def traverse_history(expression)
110
+ page_command("Runtime.evaluate", expression: expression)
111
+ wait_for_idle
114
112
  end
115
113
 
116
114
  # Common navigation lifecycle shared by `wait_for_page_load` (fresh
117
- # `Page.navigate`) and `wait_for_navigation` (back / forward / reload).
115
+ # `Page.navigate`) and `wait_for_navigation` (reload).
118
116
  # Subscribes to Page.loadEventFired, runs the trigger, waits briefly for
119
117
  # the event, falls back to readyState polling for the remaining budget.
120
118
  # The handler is unsubscribed via `ensure` so a raising trigger doesn't
@@ -1,7 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "singleton"
4
+
3
5
  module Capybara
4
6
  module Lightpanda
7
+ # Stands in for a script result that has no Ruby representation because it
8
+ # references itself — `window`, or `a.self = a` — so `returnByValue` can't
9
+ # serialize it. Ferrum returns its own `Ferrum::CyclicObject` the same way.
10
+ class CyclicObject
11
+ include Singleton
12
+
13
+ def inspect
14
+ %(#<#{self.class} JavaScript object that cannot be represented in Ruby>)
15
+ end
16
+ end
17
+
5
18
  class Browser
6
19
  # JS evaluation and RemoteObject plumbing: Runtime.evaluate /
7
20
  # callFunctionOn dispatch, result serialization (Ferrum's
@@ -23,7 +36,15 @@ module Capybara
23
36
  # later calls, like the DevTools console) but allows redeclaration.
24
37
  # Completion-value semantics cover a bare expression (`'foo'`), a
25
38
  # `throw` statement, and multi-statement scripts alike.
26
- def evaluate(expression, *args)
39
+ #
40
+ # `frame` (an `<iframe>` Node, `frame_stack.last` inside `within_frame`)
41
+ # runs the script against that frame's globals — see call_with_args.
42
+ # There the script goes through the frame's indirect `eval` rather than
43
+ # REPL mode: same completion-value semantics, `var` still lands on the
44
+ # frame's window, but a top-level `const`/`let` lives only for its call.
45
+ def evaluate(expression, *args, frame: nil)
46
+ return call_with_args(FRAME_EVAL_JS, [expression], frame: frame) if frame && args.empty?
47
+
27
48
  if args.empty?
28
49
  response = page_command("Runtime.evaluate", expression: expression, returnByValue: false,
29
50
  awaitPromise: true, replMode: true)
@@ -33,7 +54,7 @@ module Capybara
33
54
  end
34
55
 
35
56
  wrapped = "function() { return #{expression} }"
36
- call_with_args(wrapped, args)
57
+ call_with_args(wrapped, args, frame: frame)
37
58
  end
38
59
 
39
60
  # Execute JS without returning a value.
@@ -43,7 +64,12 @@ module Capybara
43
64
  # on JS exceptions so silent failures don't mask test bugs (the
44
65
  # previous fast path swallowed them because `awaitPromise: false` was
45
66
  # checked but `exceptionDetails` was not).
46
- def execute(expression, *args)
67
+ def execute(expression, *args, frame: nil)
68
+ if frame && args.empty?
69
+ call_with_args(FRAME_EXECUTE_JS, [expression], return_by_value: true, frame: frame)
70
+ return nil
71
+ end
72
+
47
73
  if args.empty?
48
74
  response = page_command("Runtime.evaluate", expression: expression, returnByValue: false,
49
75
  awaitPromise: false, replMode: true)
@@ -52,7 +78,7 @@ module Capybara
52
78
  end
53
79
 
54
80
  wrapped = "function() { #{expression} }"
55
- call_with_args(wrapped, args, return_by_value: false)
81
+ call_with_args(wrapped, args, return_by_value: false, frame: frame)
56
82
  nil
57
83
  end
58
84
 
@@ -77,7 +103,7 @@ module Capybara
77
103
  # Evaluate async JS with a callback. The user's script receives
78
104
  # the callback as its last argument (`arguments[arguments.length - 1]`),
79
105
  # matching Capybara's evaluate_async_script contract.
80
- def evaluate_async(expression, *args, wait: @options.timeout)
106
+ def evaluate_async(expression, *args, wait: @options.timeout, frame: nil)
81
107
  timeout_ms = (wait * 1000).to_i
82
108
  wrapped = <<~JS
83
109
  function() {
@@ -92,7 +118,7 @@ module Capybara
92
118
  });
93
119
  }
94
120
  JS
95
- call_with_args(wrapped, args)
121
+ call_with_args(wrapped, args, frame: frame)
96
122
  end
97
123
 
98
124
  # Evaluate JS and return a RemoteObject reference (for DOM nodes, arrays).
@@ -142,6 +168,19 @@ module Capybara
142
168
  # itself timed out / failed in transport.
143
169
  end
144
170
 
171
+ # Compiles the declaration (first argument) with the frame's `eval` and
172
+ # applies it to the rest. `this` is the `<iframe>` element.
173
+ FRAME_APPLY_JS = <<~JS
174
+ function(declaration) {
175
+ var fn = this.contentWindow.eval('(' + declaration + ')');
176
+ return fn.apply(null, Array.prototype.slice.call(arguments, 1));
177
+ }
178
+ JS
179
+ # Indirect eval: global code in the frame, like a classic script.
180
+ FRAME_EVAL_JS = "function(source) { return (0, eval)(source); }"
181
+ # No return, so a script ending in a pending Promise isn't awaited.
182
+ FRAME_EXECUTE_JS = "function(source) { (0, eval)(source); }"
183
+
145
184
  private
146
185
 
147
186
  def serialize_argument(arg)
@@ -167,17 +206,32 @@ module Capybara
167
206
  # When `return_by_value: false` (the default) the return value is unwrapped via
168
207
  # `unwrap_call_result` so that DOM nodes come back as `{ "__lightpanda_node__" => ... }`
169
208
  # hashes the Driver can wrap as Capybara nodes.
170
- def call_with_args(function_declaration, args, return_by_value: false)
171
- # document_object_id returns a fresh RemoteObject handle every call.
172
- # Release it on the way out so long-running shared-spec sessions don't
173
- # accumulate orphaned V8 handles between resets.
174
- doc_oid = document_object_id
209
+ #
210
+ # With `frame`, the function is compiled by the frame's own `eval`, so
211
+ # its free names (`window`, `document`, `location`) are the frame's, and
212
+ # called from the `<iframe>` element. Running it in the frame's own
213
+ # execution context instead would not do: Lightpanda runs every
214
+ # callFunctionOn in the top context, so node handles found inside a
215
+ # frame belong to that context and can't be passed into another
216
+ # ("Argument should belong to the same JavaScript world").
217
+ def call_with_args(function_declaration, args, return_by_value: false, frame: nil)
218
+ arguments = args.map { |a| serialize_argument(a) }
219
+ if frame
220
+ target = frame.remote_object_id
221
+ arguments.unshift({ value: function_declaration })
222
+ function_declaration = FRAME_APPLY_JS
223
+ else
224
+ # document_object_id returns a fresh RemoteObject handle every call.
225
+ # Release it on the way out so long-running shared-spec sessions
226
+ # don't accumulate orphaned V8 handles between resets.
227
+ target = doc_oid = document_object_id
228
+ end
175
229
  params = {
176
- objectId: doc_oid,
230
+ objectId: target,
177
231
  functionDeclaration: function_declaration,
178
232
  returnByValue: return_by_value,
179
233
  awaitPromise: true,
180
- arguments: args.map { |a| serialize_argument(a) },
234
+ arguments: arguments,
181
235
  }
182
236
  response = page_command("Runtime.callFunctionOn", **params)
183
237
  raise_on_js_error!("call_with_args", function_declaration, response)
@@ -216,6 +270,11 @@ module Capybara
216
270
  # Re-fetch a remote object as JSON-serializable value for plain objects/arrays.
217
271
  # Cheaper than walking properties and good enough for shared specs. Releases
218
272
  # the original handle so long-lived sessions don't accumulate leaked objectIds.
273
+ #
274
+ # V8 refuses to return a cyclic object by value. Through 1.0.0 every
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.
219
278
  def serialize_remote_object(object_id)
220
279
  json = page_command(
221
280
  "Runtime.callFunctionOn",
@@ -224,10 +283,17 @@ module Capybara
224
283
  returnByValue: true
225
284
  )
226
285
  handle_evaluate_response(json, "function() { return this }")
286
+ rescue BrowserError => e
287
+ raise unless CYCLIC_VALUE_ERRORS.any? { |message| e.message.to_s.include?(message) }
288
+
289
+ CyclicObject.instance
227
290
  ensure
228
291
  release_object(object_id)
229
292
  end
230
293
 
294
+ CYCLIC_VALUE_ERRORS = ["Object reference chain is too long", "Object couldn't be returned by value"].freeze
295
+ private_constant :CYCLIC_VALUE_ERRORS
296
+
231
297
  # Walk an array's own indexed properties via `Runtime.getProperties`,
232
298
  # unwrapping each element through the regular result pipeline so that
233
299
  # DOM-node entries surface as `{ "__lightpanda_node__" => ... }` instead
@@ -85,9 +85,10 @@ module Capybara
85
85
  # Selenium's `execute_async_script`: the script receives a completion
86
86
  # callback as its last argument. Same contract as
87
87
  # Driver#evaluate_async_script, minus the DOM-node unwrapping — callers
88
- # on this path (axe-core et al.) hand back plain JSON.
88
+ # on this path (axe-core et al.) hand back plain JSON. Runs in the
89
+ # current frame, as Selenium's does.
89
90
  def execute_async_script(script, *)
90
- evaluate_async(script.to_s.strip, *)
91
+ evaluate_async(script.to_s.strip, *, frame: frame_stack.last)
91
92
  end
92
93
 
93
94
  # Selenium's raw-CDP escape hatch, as used by
@@ -166,6 +166,7 @@ module Capybara
166
166
  subscribe_to_console_logs
167
167
  subscribe_to_console_capture
168
168
  subscribe_to_execution_context
169
+ subscribe_to_navigation
169
170
  subscribe_to_turbo_signals
170
171
  # Network owns the Network.* domain: enabling installs traffic
171
172
  # tracking AND the navigation-response capture behind status_code.
@@ -423,17 +424,25 @@ module Capybara
423
424
  # (title, current_url, …) without racing the navigation lifecycle.
424
425
  #
425
426
  # Sniff window: the action returns synchronously, but the CDP events
426
- # signalling its async fallout (Runtime.executionContextsCleared for
427
- # full nav; the turbo sentinel for Turbo) arrive later on the dispatch
428
- # thread. We poll briefly for either signal — if neither fires within
429
- # the window, assume the action was inert and exit fast.
427
+ # signalling its async fallout arrive later on the dispatch thread. We
428
+ # poll briefly for any of them — a main-frame navigation start (see
429
+ # subscribe_to_navigation), Runtime.executionContextsCleared, or the
430
+ # turbo sentinel — and if none fires within the window, assume the
431
+ # action was inert and exit fast. Lightpanda announces a navigation
432
+ # ~5 ms after the click but only swaps the execution context once the
433
+ # response lands, so watching the context alone returned on the old
434
+ # document whenever the server took longer than the window.
430
435
  SNIFF_WINDOW = 0.05
431
436
  private_constant :SNIFF_WINDOW
432
437
 
433
438
  def wait_for_idle
434
439
  prior_context_iteration = @default_context_event.iteration
440
+ prior_navigation_iteration = @navigation_event.iteration
435
441
  sniff_deadline = monotonic_time + SNIFF_WINDOW
442
+ navigating = false
436
443
  loop do
444
+ navigating = @navigation_event.iteration > prior_navigation_iteration
445
+ break if navigating
437
446
  break if @default_context_event.iteration > prior_context_iteration
438
447
  break unless @turbo_event.set?
439
448
  break if monotonic_time > sniff_deadline
@@ -441,6 +450,9 @@ module Capybara
441
450
  sleep 0.001
442
451
  end
443
452
 
453
+ # Only a navigation this action started is awaited: one left pending
454
+ # by an earlier load must not make every later click pay the timeout.
455
+ @navigation_event.wait(@options.timeout) if navigating
444
456
  @default_context_event.wait(@options.timeout)
445
457
  @turbo_event.wait(@options.timeout)
446
458
  check_unhandled_modal!
@@ -607,6 +619,53 @@ module Capybara
607
619
  page_command("Runtime.enable")
608
620
  end
609
621
 
622
+ # Track cross-document navigations of the main frame, from the moment
623
+ # they start until the frame stops loading. Same-document ones
624
+ # (pushState, fragment) emit Page.navigatedWithinDocument instead and
625
+ # never reset the event, so they don't make wait_for_idle block; nor do
626
+ # iframe navigations (other frameId). The main frame shares the
627
+ # target's id.
628
+ #
629
+ # It ends at whichever comes first: the main frame's new default
630
+ # execution context (the new document committed — enough even when
631
+ # the load event never comes, lightpanda-io/browser#1801) or
632
+ # Page.frameStoppedLoading. A navigation whose request fails
633
+ # (connection refused, DNS) emits neither — only Network.loadingFailed for the
634
+ # document request — so that ends it too, keyed by the requestId the
635
+ # main-frame Document request was sent with. Needs Page.enable, which
636
+ # the first go_to turns on; a page that was never visited has no
637
+ # navigation to click into.
638
+ def subscribe_to_navigation
639
+ @navigation_event = Utils::Event.new
640
+ @navigation_event.set
641
+ @navigation_request_id = nil
642
+
643
+ on("Page.frameStartedNavigating") do |params|
644
+ @navigation_event.reset if main_frame_navigation_start?(params)
645
+ end
646
+ on("Network.requestWillBeSent") do |params|
647
+ @navigation_request_id = params["requestId"] if main_frame_document_request?(params)
648
+ end
649
+ on("Page.frameStoppedLoading") do |params|
650
+ @navigation_event.set if params["frameId"] == @target_id
651
+ end
652
+ on("Runtime.executionContextCreated") do |params|
653
+ aux = params.dig("context", "auxData") || {}
654
+ @navigation_event.set if aux["isDefault"] && aux["frameId"] == @target_id
655
+ end
656
+ on("Network.loadingFailed") do |params|
657
+ @navigation_event.set if params["requestId"] == @navigation_request_id
658
+ end
659
+ end
660
+
661
+ def main_frame_navigation_start?(params)
662
+ params["frameId"] == @target_id && !params["navigationType"].to_s.match?(/same/i)
663
+ end
664
+
665
+ def main_frame_document_request?(params)
666
+ params["type"] == "Document" && params["frameId"] == @target_id
667
+ end
668
+
610
669
  def close_client_silently
611
670
  @client&.close
612
671
  rescue StandardError
@@ -33,14 +33,19 @@ module Capybara
33
33
  @driver_mutex.synchronize { @driver.text(message) }
34
34
  end
35
35
 
36
+ # The socket is closed even when the connection is already dead. A
37
+ # browser that dies first makes the reader thread #mark_dead the
38
+ # connection (status :closed or :error) without closing the socket,
39
+ # and an early return here then left the TCPSocket — one descriptor
40
+ # per crashed browser — open until GC finalized it (ferrum #639).
36
41
  def close
37
- return if @status == :closed
38
-
39
- @status = :closing
40
- @messages.close
41
- @driver_mutex.synchronize { @driver&.close }
42
- @thread&.join(1) || @thread&.kill
43
- @socket&.close
42
+ unless @status == :closed
43
+ @status = :closing
44
+ @messages.close
45
+ @driver_mutex.synchronize { @driver&.close }
46
+ @thread&.join(1) || @thread&.kill
47
+ end
48
+ close_socket
44
49
  @status = :closed
45
50
  end
46
51
 
@@ -54,8 +59,24 @@ module Capybara
54
59
  mark_dead
55
60
  end
56
61
 
62
+ # Single home for the "dead implies queue closed" invariant: every
63
+ # path that gives up on the connection must close @messages so the
64
+ # Client message thread's blocking pop returns. Public because the
65
+ # Client calls it when its own message thread dies — either transport
66
+ # thread dying means the connection is dead.
67
+ def mark_dead(status = :closed)
68
+ @status = status
69
+ @messages.close
70
+ end
71
+
57
72
  private
58
73
 
74
+ def close_socket
75
+ @socket&.close unless @socket.nil? || @socket.closed?
76
+ rescue IOError, SystemCallError
77
+ # Already torn down by the peer; nothing left to release.
78
+ end
79
+
59
80
  def connect
60
81
  uri = URI.parse(@url)
61
82
 
@@ -95,19 +116,25 @@ module Capybara
95
116
 
96
117
  @driver.on(:error) do |event|
97
118
  # Do NOT raise here. This callback fires synchronously from
98
- # @driver.parse(data) inside the reader thread, which sets
99
- # abort_on_exception = true. Raising DeadBrowserError escapes
100
- # the reader's narrow IO rescue and aborts the entire Ruby
101
- # process. Mark the connection dead and let Client#command
102
- # surface DeadBrowserError on its next dispatch via closed?.
119
+ # @driver.parse(data) inside the reader thread. Mark the
120
+ # connection dead and let Client#command surface DeadBrowserError
121
+ # on its next dispatch via closed?.
103
122
  @logger&.puts("✗ WebSocket error: #{event.message}")
104
123
  mark_dead(:error)
105
124
  end
106
125
  end
107
126
 
127
+ # The reader must never take the host process down. With
128
+ # abort_on_exception an error the narrow IO rescue doesn't expect
129
+ # (Errno::ETIMEDOUT is a SystemCallError, not an IOError) was re-raised
130
+ # on the main thread wherever it happened to be: a spec failure blamed
131
+ # on an unrelated call, or a server dying with its `ensure` never
132
+ # reaching browser.quit. Any error here means the connection is dead —
133
+ # report it and let the next command raise DeadBrowserError
134
+ # (mirrors ferrum #632).
108
135
  def start_reader_thread
109
136
  @thread = Thread.new do
110
- Thread.current.abort_on_exception = true
137
+ Thread.current.abort_on_exception = false
111
138
 
112
139
  loop do
113
140
  break if @status == :closed || @status == :closing
@@ -120,6 +147,10 @@ module Capybara
120
147
  rescue Errno::ECONNRESET, Errno::EPIPE, IOError
121
148
  mark_dead
122
149
  break
150
+ rescue StandardError => e
151
+ warn "Capybara::Lightpanda: WebSocket reader died: #{e.class}: #{e.message}"
152
+ mark_dead(:error)
153
+ break
123
154
  end
124
155
  end
125
156
  end
@@ -144,14 +175,6 @@ module Capybara
144
175
  raise TimeoutError, "WebSocket handshake timed out after #{@options.handshake_timeout}s"
145
176
  end
146
177
 
147
- # Single home for the "dead implies queue closed" invariant: every
148
- # path that gives up on the connection must close @messages so the
149
- # Client message thread's blocking pop returns.
150
- def mark_dead(status = :closed)
151
- @status = status
152
- @messages.close
153
- end
154
-
155
178
  def parse_message(data)
156
179
  JSON.parse(data, max_nesting: false)
157
180
  rescue JSON::ParserError => e
@@ -102,12 +102,31 @@ module Capybara
102
102
  @pendings.each_value { |ivar| ivar.try_set(nil) }
103
103
  end
104
104
 
105
+ # The message thread must never take the host process down: with
106
+ # abort_on_exception, anything handle_message raised was re-raised on
107
+ # the main thread mid-whatever-it-was-doing. An unexpected error here
108
+ # means the connection is dead — mark it so and stop (ferrum #632).
109
+ #
110
+ # The `ensure` is also the fail-fast for a connection that dies
111
+ # *underneath* us: the WebSocket's mark_dead closes @messages, pop
112
+ # returns nil, the loop ends, and every blocked caller is released to
113
+ # raise DeadBrowserError instead of waiting out its full timeout
114
+ # (ferrum #630). Ordering is what makes that work — mark_dead flips the
115
+ # status before it closes the queue, so by the time the waiters wake,
116
+ # `@ws.closed?` is already true.
105
117
  def start_message_thread
106
118
  @message_thread = Thread.new do
107
- Thread.current.abort_on_exception = true
108
-
109
- while (message = @ws.messages.pop)
110
- handle_message(message)
119
+ Thread.current.abort_on_exception = false
120
+
121
+ begin
122
+ while (message = @ws.messages.pop)
123
+ handle_message(message)
124
+ end
125
+ rescue StandardError => e
126
+ warn "Capybara::Lightpanda: CDP message thread died: #{e.class}: #{e.message}"
127
+ @ws.mark_dead(:error)
128
+ ensure
129
+ fail_pending_commands
111
130
  end
112
131
  end
113
132
  end
@@ -117,8 +136,8 @@ module Capybara
117
136
  pending = @pendings[message["id"]]
118
137
  # try_set, not set: a duplicate frame for an already-answered id
119
138
  # (Lightpanda emits occasional malformed/duplicate frames — see
120
- # upstream-wishlist.md A41) would raise MultipleAssignmentError on
121
- # this thread, and abort_on_exception would kill the whole process.
139
+ # upstream-wishlist.md A41) would raise MultipleAssignmentError and
140
+ # kill the connection for a frame we should simply ignore.
122
141
  pending&.try_set(message)
123
142
  elsif message["method"]
124
143
  @subscriber.dispatch(message["method"], message["params"])