capybara-lightpanda 0.11.0 → 0.13.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: 6b898fd26a9e25f95263b605f022264cd2a132bbca82081bd931fe542c88673e
4
- data.tar.gz: 4776e1326f56cd6f123538b130dfb7de1edb1a745a45574e31520a1a5227ce94
3
+ metadata.gz: 3c435f7a8f49c34c68012f2c34cb4f9766531acbdd61e9e3f70e4ac9395d54e3
4
+ data.tar.gz: 11cca4dc77a1b415eff5e0e45796e9f806ee36169c8ad0c672c0947d2281f834
5
5
  SHA512:
6
- metadata.gz: 025d4fd23a6f0b4379c6e34d878610b657a06dcf33ab5490b606e5e42ed2e452475860283df232f8c397bbf1b48144f0d89be33bf7e5a70fe430a14e26bfb1a7
7
- data.tar.gz: 16193011f9ea880c74300a45491ab5dbe36abe55e0a080fe1f65edf36d9722daf6e9842de361f498068952970f3f2d975a64d8b92eb8ecaf2600c01b342caf34
6
+ metadata.gz: '07464388df9304ea1f4bf3bbde2c6dfd1c654d787690d47d3d3d62f6f89c22860a24185f14c839979e0d6fb1aebfb030aee97c450894d8da418357341994a4dd'
7
+ data.tar.gz: 03d0b12cf3cb0728639faed14706fffce40f1287f65b714f4126d4a4da8063071ac9423e6a1b040e68dfc6473e451d29658944a8bfa4d5db3c462520c0b260c6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.13.0] - 2026-10-03
4
+
5
+ > **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.
6
+
7
+ ### Changed
8
+
9
+ - **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).
10
+ - **`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.
11
+
12
+ ### Fixed
13
+
14
+ - **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.
15
+ - **`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.
16
+ - **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.
17
+ - **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.
18
+ - **`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.
19
+ - **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.
20
+ - **`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.
21
+ - **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.
22
+ - **`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`.
23
+
24
+ ## [0.12.0] - 2026-08-28
25
+
26
+ > **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.
27
+
28
+ ### Added
29
+
30
+ - **The keyboard activates things.** Pressing Enter on a button, link or submit input — and Space on a button, checkbox or radio — now does what it does in a real browser: the control activates, forms submit, checkboxes toggle. Previously `send_keys(:enter)` and `send_keys(:space)` were silently ignored on those controls, so keyboard-accessibility specs passed by doing nothing. Printable keys also emit `keypress` now, and a handler that cancels `keypress` correctly suppresses Enter's implicit form submission.
31
+ - **A closed `<dialog>` is properly hidden.** Text and controls inside a `<dialog>` that has never been opened no longer match Capybara's default visibility filter. Suites using the `<dialog>`-as-confirmation idiom (Turbo, Spree 5's admin) could previously "find" and click a confirm button that was not on screen — passing where Chrome would have raised `ElementNotFound`.
32
+ - **Uppercase inline styles hide elements.** `style="display: NONE"` and `style="visibility: HIDDEN"` are now treated as hidden, matching the CSS spec's case-insensitive keywords. Legacy templates and CMS output that upcase keywords no longer read as visible.
33
+ - **`ws_url:` accepts `localhost`.** Connecting to an externally-managed Lightpanda over `ws://localhost:PORT/` works; previously only an IP literal such as `ws://127.0.0.1:PORT/` was accepted by the browser's handshake.
34
+
35
+ ### Fixed
36
+
37
+ - **Keyboard-triggered navigation is waited for.** Now that Enter activates a submit button, pressing it starts a real navigation — but `send_keys` returned without waiting for it, so the very next `current_url` (or any non-waiting assertion) could still report the previous page. Keystrokes now settle the page the same way a click does. As with a click, a navigation whose server round-trip is slow still needs one of Capybara's waiting matchers, such as `have_current_path`.
38
+
39
+ - **Iframes keep working when the browser stops loading them by default.** Lightpanda build 8946 turns off `<iframe>` and Web Worker loading unless asked: the parser still puts the iframe in the DOM, but no frame is ever created, so `within_frame` and `switch_to_frame` find nothing and report no cause. The driver now asks for both on every session, which holds today's behavior across the change and needs no newer browser — the request is understood by every build the gem supports. Nothing to configure. If you run the rolling nightly, take this before your browser passes 8946.
40
+
41
+ - **A browser you manage yourself is version-checked like one the gem spawns.** `ws_url:` was the only way into the driver that skipped the version floor: a Lightpanda too old for the gem connected happily, then failed further along as a CDP error naming neither the version nor the option that let it through. The driver now reads the version over CDP at connect and refuses anything below the floor, with the same message the spawn path produces. An endpoint that cannot report a version at all — a Lightpanda older than the command, or a Chrome pointed at by mistake — is refused for the same reason. `page.driver.browser.version`, `.nightly_build` and `.release` report the browser on this path now instead of returning `nil`.
42
+
3
43
  ## [0.11.0] - 2026-08-25
4
44
 
5
45
  > **Update Lightpanda before upgrading.** This release requires a Lightpanda nightly build ≥ 8796 (2026-08-25 or newer). **No tagged Lightpanda release is new enough yet**: 0.3.7 is below the floor and is now refused, so version pinning is unavailable until Lightpanda ships its next release — CI runs on the rolling nightly in the meantime.
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 8796 — 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
  }
@@ -73,28 +73,34 @@ module Capybara
73
73
  end
74
74
  end
75
75
 
76
- # Lightpanda binary version (e.g. "lightpanda 0.2.9 nightly.5267") and
77
- # parsed nightly build number, captured at Process startup. nil when
78
- # the gem is connecting to an externally-managed Lightpanda via ws_url.
76
+ # Lightpanda version string (e.g. "1.0.0-nightly.8925+a7bda0ea5") and its
77
+ # parsed build number. Learned from `lightpanda version` at Process
78
+ # startup on the spawn path, and from the CDP `LP.version` command when
79
+ # connecting to an externally-managed browser via ws_url — so these are
80
+ # populated either way, and a caller inspecting them does not have to
81
+ # know which path built the session.
79
82
  def version
80
- @process&.version
83
+ @process&.version || @remote_version
81
84
  end
82
85
 
83
86
  # Set on the nightly/dev channel only; nil for a tagged release, which
84
87
  # carries no build counter. `release` is its mirror image — exactly one of
85
- # the two is non-nil once the process has started.
88
+ # the two is non-nil once the session is up.
86
89
  def nightly_build
87
- @process&.nightly_build
90
+ @process&.nightly_build || @remote_nightly_build
88
91
  end
89
92
 
90
93
  def release
91
- @process&.release
94
+ @process&.release || @remote_release
92
95
  end
93
96
 
94
97
  def initialize(options = {})
95
98
  @options = Options.new(options)
96
99
  @process = nil
97
100
  @client = nil
101
+ @remote_version = nil
102
+ @remote_nightly_build = nil
103
+ @remote_release = nil
98
104
  @target_id = nil
99
105
  @session_id = nil
100
106
  @browser_context_id = nil
@@ -121,6 +127,7 @@ module Capybara
121
127
 
122
128
  if @options.ws_url?
123
129
  @client = Client.new(@options.ws_url, @options)
130
+ check_remote_version
124
131
  else
125
132
  @process = Process.new(@options)
126
133
  @process.start
@@ -152,6 +159,9 @@ module Capybara
152
159
  attach_result = @client.command("Target.attachToTarget", { targetId: @target_id, flatten: true })
153
160
  @session_id = attach_result["sessionId"]
154
161
 
162
+ # Must precede the first navigation: sub-resource loading is a
163
+ # per-BrowserContext setting read while the document loads.
164
+ configure_loading
155
165
  @turbo_event.set
156
166
  subscribe_to_console_logs
157
167
  subscribe_to_console_capture
@@ -498,6 +508,34 @@ module Capybara
498
508
 
499
509
  private
500
510
 
511
+ # Keep iframes and workers loading.
512
+ #
513
+ # Upstream #3305 (build 8946) turned `Config.LoadResources` into a
514
+ # four-field struct defaulting every sub-resource to false, so
515
+ # `Frame.iframeAddedCallback` now returns early unless `iframe` is set:
516
+ # the parser still registers the `<iframe>` in the DOM, but no child
517
+ # frame is ever created. That silently breaks `switch_to_frame` and
518
+ # Capybara's `within_frame` — core driver API, not an optional extra —
519
+ # and there is no CLI opt-in the gem can use below 8946, because the
520
+ # `--load-resources` *value* `iframe` only parses from that build.
521
+ #
522
+ # The `subFrame`/`worker` params predate #3305 and simply write a
523
+ # different field after it, which makes this the one form that spans the
524
+ # whole supported range: a no-op below 8946 where both already defaulted
525
+ # on, load-bearing above it, and no MINIMUM_NIGHTLY_BUILD bump either way.
526
+ #
527
+ # Workers are included to hold the gem's behavior still across the flip
528
+ # rather than to satisfy a feature we ship: a worker that silently never
529
+ # runs is the same debugging trap as an iframe that silently never loads.
530
+ #
531
+ # Not rescued on purpose. LP.configureLoading predates the floor by a
532
+ # wide margin, so a failure here means the endpoint is not a Lightpanda
533
+ # we understand, and swallowing it would restore the exact silence this
534
+ # call exists to prevent.
535
+ def configure_loading
536
+ page_command("LP.configureLoading", subFrame: true, worker: true)
537
+ end
538
+
501
539
  def register_auto_scripts
502
540
  page_command("Page.addScriptToEvaluateOnNewDocument", source: AutoScripts::JS)
503
541
  end
@@ -590,6 +628,44 @@ module Capybara
590
628
  end
591
629
  end
592
630
 
631
+ # Binary.update_hint would be a lie here: it prints a curl into a path the
632
+ # gem controls, and under ws_url the browser is somebody else's process on
633
+ # possibly another host.
634
+ REMOTE_UPDATE_HINT = "the gem does not manage that browser — " \
635
+ "update the Lightpanda serving ws_url yourself."
636
+ private_constant :REMOTE_UPDATE_HINT
637
+
638
+ # The spawn path runs `lightpanda version` before the browser is even up
639
+ # (Process#check_minimum_version). An externally-managed browser has no
640
+ # binary to shell out to, so the same floor is enforced over CDP instead:
641
+ # `LP.version` returns the identical string the CLI prints, and
642
+ # Process.check_version! is the one parser both channels share.
643
+ #
644
+ # Without this, `ws_url:` was the one way into the driver that skipped the
645
+ # floor entirely — an old browser connected happily and then failed later
646
+ # as an unrelated-looking CDP error, with nothing naming the real cause.
647
+ #
648
+ # A browser that cannot answer `LP.version` is refused rather than assumed
649
+ # new enough, matching the spawn path's rule that an unidentifiable binary
650
+ # never passes. Two things land here: a Lightpanda predating the LP domain,
651
+ # and a Chrome someone pointed ws_url at by mistake — the second being easy
652
+ # to do and previously diagnosable only by watching later commands fail.
653
+ #
654
+ # Only #start calls this. #reconnect deliberately does not: it re-dials the
655
+ # same endpoint, whose version cannot have changed, and the crash-recovery
656
+ # path should not spend a round-trip re-asking.
657
+ def check_remote_version
658
+ result = @client.command("LP.version")
659
+ @remote_version, @remote_nightly_build, @remote_release =
660
+ Process.check_version!(result["version"]) { REMOTE_UPDATE_HINT }
661
+ rescue BrowserError, TimeoutError => e
662
+ raise BinaryError,
663
+ "Could not read the Lightpanda version at #{@options.ws_url} " \
664
+ "(LP.version failed: #{e.message}). ws_url: must point at a " \
665
+ "Lightpanda CDP endpoint new enough to answer LP.version; the gem " \
666
+ "cannot verify a browser it does not manage."
667
+ end
668
+
593
669
  def restart_process_if_dead
594
670
  return unless @process && !@process.alive?
595
671
 
@@ -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"])
@@ -79,7 +79,12 @@ module Capybara
79
79
  # Ferrum/Cuprite spelling: `browser.cookies["session_id"]`.
80
80
  alias [] get
81
81
 
82
- def set(name:, value:, domain: nil, path: "/", secure: false, http_only: false, # rubocop:disable Metrics/ParameterLists
82
+ # Returns whether the browser accepted the cookie (ferrum parity).
83
+ # Lightpanda answers `{success: false}` and drops the cookie for an
84
+ # invalid combination — notably `same_site: "None"` without `secure`
85
+ # since upstream #3529 (build 9877), Chrome's rule — so callers that
86
+ # need it to stick should check the result.
87
+ def set(name:, value:, domain: nil, path: "/", secure: false, http_only: false, # rubocop:disable Metrics/ParameterLists, Naming/PredicateMethod
83
88
  same_site: nil, expires: nil)
84
89
  params = {
85
90
  name: name,
@@ -96,7 +101,8 @@ module Capybara
96
101
  params[:sameSite] = same_site if %w[Strict Lax None].include?(same_site)
97
102
  params[:expires] = expires.to_i if expires
98
103
 
99
- browser.command("Network.setCookie", **params)
104
+ result = browser.command("Network.setCookie", **params)
105
+ !(result.is_a?(Hash) && result["success"] == false)
100
106
  end
101
107
 
102
108
  def remove(name:, domain: nil, path: "/")
@@ -119,10 +125,13 @@ module Capybara
119
125
  # Load cookies from a YAML file produced by `store` and re-set them.
120
126
  # CDP requires either domain or url for each cookie; entries from `store`
121
127
  # already include domain, so they round-trip cleanly. Returns true on
122
- # success (intentionally not a predicate — mirrors ferrum's API).
128
+ # success (intentionally not a predicate — mirrors ferrum's API). A
129
+ # cookie the browser refuses is reported on stderr rather than raised,
130
+ # so one bad entry doesn't abort restoring the rest.
123
131
  def load(path = "cookies.yml") # rubocop:disable Naming/PredicateMethod
124
132
  cookies = YAML.load_file(path)
125
- cookies.each { |c| restore_cookie(c) }
133
+ rejected = cookies.reject { |c| restore_cookie(c) }.map { |c| c.transform_keys(&:to_s)["name"] }
134
+ warn "Capybara::Lightpanda: the browser refused cookies from #{path}: #{rejected.join(', ')}" if rejected.any?
126
135
  true
127
136
  end
128
137
 
@@ -140,11 +149,24 @@ module Capybara
140
149
  http_only: attrs["httpOnly"] || false,
141
150
  }
142
151
  params[:domain] = attrs["domain"] if attrs["domain"]
143
- params[:same_site] = attrs["sameSite"] if attrs["sameSite"]
152
+ same_site = restored_same_site(attrs)
153
+ params[:same_site] = same_site if same_site
144
154
  exp = attrs["expires"]
145
155
  params[:expires] = Time.at(exp) if exp.is_a?(Numeric) && exp.positive?
146
156
  set(**params)
147
157
  end
158
+
159
+ # Lightpanda below build 9877 reported every cookie without a SameSite
160
+ # attribute as "None", so a file stored then is full of insecure
161
+ # SameSite=None cookies — which the browser now rejects (Chrome's rule,
162
+ # upstream #3529). Leaving SameSite unset restores what the cookie
163
+ # actually was: unspecified, i.e. Lax-by-default.
164
+ def restored_same_site(attrs)
165
+ same_site = attrs["sameSite"]
166
+ return nil if same_site == "None" && !attrs["secure"]
167
+
168
+ same_site
169
+ end
148
170
  end
149
171
  end
150
172
  end
@@ -12,5 +12,6 @@ window._lightpanda = {
12
12
  isObscured: isObscured,
13
13
  isDisabled: isDisabled,
14
14
  isContentEditable: isContentEditable,
15
+ pointerFocusTarget: pointerFocusTarget,
15
16
  visibleText: visibleText
16
17
  };
@@ -17,7 +17,7 @@
17
17
  // separately because checkVisibility's defaults (per spec) ignore it.
18
18
  //
19
19
  // External `<link rel=stylesheet>` ARE fetched and applied — the gem
20
- // always passes --enable-external-stylesheets (Lightpanda PR #2487,
20
+ // always passes `--load-resources stylesheet` (Lightpanda PR #2487,
21
21
  // guaranteed by the build floor) — so stylesheet-driven `display:none`
22
22
  // participates in this check. The remaining gap is viewport emulation:
23
23
  // `@media` rules and `matchMedia()` evaluate against the hardcoded
@@ -92,6 +92,43 @@ function isContentEditable(el) {
92
92
  return false;
93
93
  }
94
94
 
95
+ // The element a real pointer press on `el` would focus: the nearest focusable
96
+ // inclusive ancestor, or null when the press lands on nothing focusable (Chrome
97
+ // then moves focus to the body). Lightpanda's handleClick stopped focusing
98
+ // controls in upstream #3702 (build 9968), and an untrusted `mousedown` — the
99
+ // only kind CLICK_JS can dispatch — never runs the focus default, so the
100
+ // driver has to apply it itself or `click` then `page.send_keys` types nowhere.
101
+ function pointerFocusTarget(el) {
102
+ function isEditingHost(n) {
103
+ if (!n.hasAttribute || !n.hasAttribute('contenteditable')) return false;
104
+ return (n.getAttribute('contenteditable') || '').toLowerCase() !== 'false';
105
+ }
106
+ function isFocusable(n) {
107
+ if (isDisabled(n)) return false;
108
+ switch ((n.tagName || '').toUpperCase()) {
109
+ case 'INPUT': return (n.getAttribute('type') || '').toLowerCase() !== 'hidden';
110
+ case 'SELECT': case 'TEXTAREA': case 'BUTTON': case 'IFRAME': case 'SUMMARY': return true;
111
+ case 'A': case 'AREA': return n.hasAttribute('href');
112
+ }
113
+ return n.hasAttribute('tabindex');
114
+ }
115
+
116
+ for (var n = el; n && n.nodeType === 1; n = n.parentElement) {
117
+ // A press on a <label> lands on its control (the label's activation
118
+ // behavior); the label itself is never focusable.
119
+ if ((n.tagName || '').toUpperCase() === 'LABEL' && n.control) {
120
+ return isFocusable(n.control) ? n.control : null;
121
+ }
122
+ if (isEditingHost(n)) {
123
+ // Focus goes to the outermost element of a contiguous editable region.
124
+ while (n.parentElement && isEditingHost(n.parentElement)) n = n.parentElement;
125
+ return n;
126
+ }
127
+ if (isFocusable(n)) return n;
128
+ }
129
+ return null;
130
+ }
131
+
95
132
  // Capybara's "visible text" = WebDriver getText semantics, which native
96
133
  // innerText alone doesn't provide at two edges: a non-rendered element must
97
134
  // yield "" (innerText returns textContent per the HTML getter's step 1), and a
@@ -171,19 +171,37 @@ module Capybara
171
171
  release_modifiers(active_mods)
172
172
  end
173
173
 
174
+ # A chord with Ctrl or Meta is a command, not text: Chrome inserts
175
+ # nothing for Ctrl+A, it selects. Since upstream #3542 (build 9580)
176
+ # Lightpanda inserts whatever the keyDown's `text` carries, so sending
177
+ # `text` with Ctrl held typed a stray "a" into the field. The keyDown
178
+ # still names the key, so shortcut handlers (`e.ctrlKey && e.key ===
179
+ # "a"`) fire exactly as before.
180
+ COMMAND_MODIFIERS = %i[ctrl control meta command].freeze
181
+ private_constant :COMMAND_MODIFIERS
182
+
174
183
  def dispatch_modified_char(char, modifier_value, modifiers)
175
- text = modifiers.include?(:shift) ? self.class.shifted(char) : char
176
- send_key_event("keyDown", { key: text, text: text, unmodifiedText: char }, modifiers: modifier_value)
177
- send_key_event("keyUp", { key: text }, modifiers: modifier_value)
184
+ key = modifiers.include?(:shift) ? self.class.shifted(char) : char
185
+ definition = { key: key, unmodifiedText: char }
186
+ definition[:text] = key unless modifiers.intersect?(COMMAND_MODIFIERS)
187
+ send_key_event("keyDown", definition, modifiers: modifier_value)
188
+ send_key_event("keyUp", { key: key }, modifiers: modifier_value)
178
189
  end
179
190
 
180
191
  def key_definition(key)
181
192
  KEYS.fetch(key) { raise ArgumentError, "Unknown key: #{key.inspect}" }
182
193
  end
183
194
 
195
+ # Always `keyDown`, never `rawKeyDown`. Chrome's convention (Ferrum,
196
+ # Puppeteer) is rawKeyDown for keys that carry no `text`, but Lightpanda's
197
+ # Input.dispatchKeyEvent drops rawKeyDown on the floor as a
198
+ # Chrome-internal type (input.zig: `if (params.type == .rawKeyDown)
199
+ # return;`). Under that convention every text-less key — Backspace,
200
+ # Delete, Tab, Escape, the arrows — silently never reached the page,
201
+ # while Enter and Space (which have `text`) did. Lightpanda synthesizes
202
+ # its own keypress from a keyDown, so there is nothing to double-fire.
184
203
  def raw_dispatch(definition, modifiers: 0)
185
- type = definition[:text] ? "keyDown" : "rawKeyDown"
186
- send_key_event(type, definition, modifiers: modifiers)
204
+ send_key_event("keyDown", definition, modifiers: modifiers)
187
205
  send_key_event("keyUp", definition, modifiers: modifiers)
188
206
  end
189
207
 
@@ -20,7 +20,7 @@ module Capybara
20
20
  @traffic = []
21
21
  @traffic_mutex = Mutex.new
22
22
  @enabled = false
23
- @request_handler = @response_handler = nil
23
+ @request_handler = @response_handler = @failure_handler = nil
24
24
  @last_navigation_response = nil
25
25
  @document_request_id = nil
26
26
  end
@@ -111,10 +111,10 @@ module Capybara
111
111
  browser.page_command("Network.setExtraHTTPHeaders", headers: {})
112
112
  end
113
113
 
114
- # Count of in-flight requests (those with no response yet recorded).
114
+ # Count of in-flight requests (no response and no failure recorded yet).
115
115
  # Cheap predicate-friendly accessor (ferrum parity).
116
116
  def pending_connections
117
- @traffic_mutex.synchronize { @traffic.count { |t| t[:response].nil? } }
117
+ @traffic_mutex.synchronize { @traffic.count { |t| open?(t) } }
118
118
  end
119
119
 
120
120
  # True when no more than `connections` requests are in-flight.
@@ -158,8 +158,10 @@ module Capybara
158
158
  def subscribe
159
159
  @request_handler = build_request_handler
160
160
  @response_handler = build_response_handler
161
+ @failure_handler = build_failure_handler
161
162
  browser.on("Network.requestWillBeSent", &@request_handler)
162
163
  browser.on("Network.responseReceived", &@response_handler)
164
+ browser.on("Network.loadingFailed", &@failure_handler)
163
165
  end
164
166
 
165
167
  # Redirects follow Chrome's shape (Lightpanda since #3175, build ≥8602):
@@ -184,6 +186,7 @@ module Capybara
184
186
  method: params.dig("request", "method"),
185
187
  timestamp: params["timestamp"],
186
188
  response: nil,
189
+ error: nil,
187
190
  }
188
191
  @traffic_mutex.synchronize do
189
192
  if (redirect = params["redirectResponse"]) && (previous = last_open_entry(params["requestId"]))
@@ -214,9 +217,31 @@ module Capybara
214
217
  end
215
218
  end
216
219
 
220
+ # A request that never gets a response — refused connection, CORS
221
+ # block, abort — ends with Network.loadingFailed instead. Without this
222
+ # handler its entry stayed open forever, so pending_connections never
223
+ # reached zero and every wait_for_idle burned its full timeout. Routine
224
+ # since Lightpanda enforces CORS by default (upstream #3654, build 9883:
225
+ # a blocked fetch reports errorText "CorsBlocked"). Ferrum closes its
226
+ # exchange on the same event.
227
+ def build_failure_handler
228
+ lambda do |params|
229
+ @traffic_mutex.synchronize do
230
+ request = last_open_entry(params["requestId"])
231
+ next unless request
232
+
233
+ request[:error] = params["errorText"].to_s
234
+ end
235
+ end
236
+ end
237
+
217
238
  # Caller holds @traffic_mutex.
218
239
  def last_open_entry(request_id)
219
- @traffic.reverse_each.find { |t| t[:request_id] == request_id && t[:response].nil? }
240
+ @traffic.reverse_each.find { |t| t[:request_id] == request_id && open?(t) }
241
+ end
242
+
243
+ def open?(entry)
244
+ entry[:response].nil? && entry[:error].nil?
220
245
  end
221
246
 
222
247
  def response_summary(response)
@@ -231,8 +256,10 @@ module Capybara
231
256
  def unsubscribe
232
257
  browser.off("Network.requestWillBeSent", @request_handler) if @request_handler
233
258
  browser.off("Network.responseReceived", @response_handler) if @response_handler
259
+ browser.off("Network.loadingFailed", @failure_handler) if @failure_handler
234
260
  @request_handler = nil
235
261
  @response_handler = nil
262
+ @failure_handler = nil
236
263
  end
237
264
  end
238
265
  end
@@ -256,9 +256,25 @@ module Capybara
256
256
  raise Capybara::UnselectNotAllowed, "Cannot unselect option from single select box."
257
257
  end
258
258
 
259
+ # The trailing `wait_for_idle` mirrors #click, and for the same reason.
260
+ # Since upstream #3264 (build >= 8842) `Input.dispatchKeyEvent` builds a
261
+ # *trusted* KeyboardEvent, so Enter on a submit input / <a href> and
262
+ # Space on a button or checkbox synthesize a real activation click —
263
+ # which means a keystroke can now start a navigation. Before #3264 it
264
+ # could not, which is why this settle step was historically absent; its
265
+ # absence then showed up as `current_url` being read against the
266
+ # outgoing document (test/features/keyboard_activation_test.rb).
267
+ #
268
+ # Caveat, shared with #click: `wait_for_idle` only sniffs for ~50 ms
269
+ # (Browser::SNIFF_WINDOW) and watches Runtime.executionContextsCleared,
270
+ # which Lightpanda emits when the *response* lands, not when the request
271
+ # starts. A navigation whose server round-trip exceeds that window is
272
+ # therefore still not awaited here — use Capybara's waiting matchers
273
+ # (`have_current_path`) for those, as you would after a click.
259
274
  def send_keys(*)
260
- call("function() { this.focus() }")
275
+ call(SEND_KEYS_FOCUS_JS)
261
276
  driver.browser.keyboard.type(*)
277
+ driver.browser.wait_for_idle
262
278
  end
263
279
 
264
280
  def tag_name
@@ -593,7 +609,21 @@ module Capybara
593
609
  // Cancelling mousedown suppresses focus/text-selection in a real
594
610
  // browser but never the click, so the click below is dispatched
595
611
  // unconditionally.
596
- hit.dispatchEvent(new EventCtor('mousedown', { bubbles: true, cancelable: true }));
612
+ var pressed = hit.dispatchEvent(new EventCtor('mousedown', { bubbles: true, cancelable: true }));
613
+ // mousedown's default action moves focus. Ours is untrusted, so the
614
+ // browser won't do it (and since upstream #3702 its click handling
615
+ // doesn't either): focus what the press landed on, or blur the
616
+ // current field when it landed on nothing focusable — Chrome's
617
+ // behavior, and what `click` then `page.send_keys` relies on.
618
+ if (pressed && window._lightpanda) {
619
+ var doc = hit.ownerDocument;
620
+ var focusTarget = _lightpanda.pointerFocusTarget(hit);
621
+ if (focusTarget) {
622
+ if (doc.activeElement !== focusTarget) focusTarget.focus();
623
+ } else if (doc.activeElement && doc.activeElement !== doc.body && doc.activeElement.blur) {
624
+ doc.activeElement.blur();
625
+ }
626
+ }
597
627
  hit.dispatchEvent(new EventCtor('mouseup', { bubbles: true, cancelable: true }));
598
628
  var clickEvt = new EventCtor('click', { bubbles: true, cancelable: true });
599
629
  var notCancelled = hit.dispatchEvent(clickEvt);
@@ -902,8 +932,46 @@ module Capybara
902
932
  }
903
933
  JS
904
934
 
935
+ # Focuses the element keys are about to be typed into, WebDriver's
936
+ # "Element Send Keys" way (what chromedriver's focus script does): an
937
+ # element that did NOT already have focus gets its caret after its
938
+ # content. Since upstream #3424 (build 9234) `Input.insertText` inserts
939
+ # at the caret, and a value seeded by the `value=` attribute leaves the
940
+ # caret at 0 — without this, `send_keys("x")` on a pre-filled field
941
+ # typed "xfoo" where Chrome types "foox". An element that already has
942
+ # focus keeps its caret, so `page.send_keys` mid-edit continues in place.
943
+ # A non-focusable target (focus() is a no-op on those since upstream
944
+ # #3592, build 9646) must not leave the previous field focused, or its
945
+ # keys land there: blur it so they go to the body, as in Chrome.
946
+ SEND_KEYS_FOCUS_JS = <<~JS
947
+ function() {
948
+ var doc = this.ownerDocument;
949
+ if (doc.activeElement === this) return;
950
+ this.focus();
951
+ if (doc.activeElement !== this) {
952
+ if (doc.activeElement && doc.activeElement !== doc.body && doc.activeElement.blur) doc.activeElement.blur();
953
+ return;
954
+ }
955
+ if (typeof this.setSelectionRange === 'function') {
956
+ // Throws on types without a selection (email, number, …) — those
957
+ // have no caret to place.
958
+ try {
959
+ var end = String(this.value || '').length;
960
+ this.setSelectionRange(end, end);
961
+ } catch (e) {}
962
+ }
963
+ }
964
+ JS
965
+
966
+ # A disabled option (or one in a disabled <optgroup>/<select>) is not
967
+ # selectable by a user, and Capybara's shared spec expects selecting one
968
+ # to be a no-op (rack_test: `return if disabled?`). Both IDL routes —
969
+ # `sel.value =` and `option.selected = true` — select it per spec;
970
+ # Lightpanda's `value` getter used to skip disabled options and hide that,
971
+ # until upstream #3502 (build 9424).
905
972
  SELECT_OPTION_JS = <<~JS
906
973
  function() {
974
+ if (_lightpanda.isDisabled(this)) return;
907
975
  var sel = this.parentElement;
908
976
  while (sel && (sel.tagName || '').toUpperCase() !== 'SELECT') sel = sel.parentElement;
909
977
  if (!sel) {
@@ -913,14 +981,10 @@ module Capybara
913
981
  // that path itself; just no-op here.
914
982
  return;
915
983
  }
916
- if (sel.multiple) {
917
- this.selected = true;
918
- } else {
919
- // Lightpanda doesn't auto-deselect siblings when we set
920
- // `option.selected`, so mirror what a real browser does and
921
- // route the change through the parent's `value`.
922
- sel.value = this.value;
923
- }
984
+ // Selects exactly this option — not the first one sharing its
985
+ // value — and, in a single <select>, deselects the rest (upstream
986
+ // #3375, guaranteed by the floor).
987
+ this.selected = true;
924
988
  sel.dispatchEvent(new Event('input', {bubbles: true}));
925
989
  sel.dispatchEvent(new Event('change', {bubbles: true}));
926
990
  }
@@ -47,13 +47,12 @@ module Capybara
47
47
  # a runaway confirm cancels the action and the spec can still pass. false
48
48
  # (default, Cuprite parity) warns on stderr; true raises
49
49
  # UnhandledModalError from the action that opened it.
50
- # load_images: passes `--load-resources image` so `<img>` elements are
51
- # fetched over the network (upstream #3230, build >= 8834). Off by
52
- # default, matching Lightpanda's default of skipping image fetches
53
- # entirely. When on, image requests ride the Network domain like any
54
- # other — they show in `network.traffic` and count toward
55
- # `wait_for_network_idle`. On builds < 8834 the flag is a fatal
56
- # UnknownOption at boot, so only enable against binaries that have it.
50
+ # load_images: adds `image` to the `--load-resources` value so `<img>`
51
+ # elements are fetched over the network (upstream #3230, build >= 8834,
52
+ # in the floor). Off by default, matching Lightpanda's default of
53
+ # skipping image fetches entirely. When on, image requests ride the
54
+ # Network domain like any other — they show in `network.traffic` and
55
+ # count toward `wait_for_network_idle`.
57
56
  attr_accessor :host, :port, :timeout, :handshake_timeout, :process_timeout,
58
57
  :window_size, :browser_path, :headless, :logger, :save_path,
59
58
  :raise_on_unhandled_modal, :load_images
@@ -63,10 +63,10 @@ module Capybara
63
63
  # returns spec-correct booleans. Lets _lightpanda.isVisible detect
64
64
  # inline-@media-gated hides via el.checkVisibility() without any
65
65
  # gem-side workaround),
66
- # PR #2487 (css: external <link rel="stylesheet"> fetch behind the
67
- # --enable-external-stylesheets flag — build_args now passes that flag
68
- # unconditionally, so the floor MUST include the build that introduced
69
- # it; the flag is a fatal UnknownOption on builds < 6353),
66
+ # PR #2487 (css: external <link rel="stylesheet"> fetch behind a CLI
67
+ # flag — build_args passes it unconditionally, so the floor MUST include
68
+ # the build that introduced it; since #3305 (8946) the spelling is
69
+ # `--load-resources stylesheet`, see the 2026-09-06 bump),
70
70
  # PR #2498 (StyleManager: author display rule beats UA [hidden] — fixes
71
71
  # the Stimulus/Alpine dropdown ElementNotFound),
72
72
  # PR #2635 (dom: DOM.setFileInputFiles backs input.files + fires change
@@ -201,7 +201,70 @@ module Capybara
201
201
  # this bump — and drag events carry integer clientX/Y (the shared spec
202
202
  # asserting that runs un-skipped). Build 8796 = the #3259 merge
203
203
  # (341a01570), which is also the 2026-08-25 nightly cut.
204
- MINIMUM_NIGHTLY_BUILD = Gem::Version.new("8796")
204
+ #
205
+ # 2026-08-26 bump 8796 -> 8875, four fixes the gem now asserts on:
206
+ # #3264 (8842) CDP Input.dispatchKeyEvent builds a *trusted*
207
+ # KeyboardEvent, so frame/user_input.zig fires `keypress` on
208
+ # printable/Enter keydowns and synthesizes a trusted PointerEvent
209
+ # click for Enter on <button>/<a href>/input[submit|button|reset|
210
+ # image] and Space-keyup on those plus checkbox/radio. That click is
211
+ # a real activation event (PointerEvent.Proto = MouseEvent), so
212
+ # Node#send_keys(:enter) submits/navigates and send_keys(:space)
213
+ # toggles a checkbox — both were no-ops below 8842. No gem code
214
+ # drives it; Keyboard never sends CDP `type: "char"`, so nothing
215
+ # double-fires. Pinned by test/features/keyboard_activation_test.rb.
216
+ # #3269 (8868) adds `dialog:not([open]) { display: none }` to the
217
+ # UA-stylesheet truth checkVisibility() and getComputedStyle().display
218
+ # share, so a closed <dialog> AND its subtree read as non-visible.
219
+ # Below 8868 Capybara matched text and controls inside a never-opened
220
+ # dialog. Pinned by test/features/upstream_bugs_test.rb (Bug #4).
221
+ # #3270 (8857) inline-style keyword matching in checkVisibility /
222
+ # getComputedStyle is case-insensitive for display / visibility /
223
+ # opacity / pointer-events, so `style="display: NONE"` hides.
224
+ # Feeds _lightpanda.isVisible. Pinned by visibility_keywords_test.rb.
225
+ # #3256 (8875) the CDP WS handshake accepts the exact lowercase
226
+ # `Host: localhost:<port>` form (bare `localhost`, `LOCALHOST:<port>`
227
+ # and `localhost.evil.com:<port>` still 403). Below 8875 only an IP
228
+ # literal passed, so a user-supplied `ws_url:` had to say 127.0.0.1.
229
+ # Pinned by test/features/ws_url_host_test.rb.
230
+ # Build 8875 = the #3256 merge (f2169836e).
231
+ #
232
+ # 2026-09-06 bump 8875 -> 9058. The binding constraint is the lockstep
233
+ # with MINIMUM_RELEASE below: 9058 is exactly `git rev-list --count
234
+ # 0.4.0`, the newest tagged release, so this is as high as the nightly
235
+ # floor can go without naming a release that does not exist. Two fixes
236
+ # the gem now asserts on ride along:
237
+ # #3298 (8937) Backspace/Delete through CDP Input.dispatchKeyEvent
238
+ # actually edit an <input>/<textarea>: a trusted `beforeinput`
239
+ # (cancelable) then `input` with inputType deleteContentBackward /
240
+ # deleteContentForward, via the text_entry.zig mixin. Below 8937 the
241
+ # keys were dead, so Node#send_keys(:backspace) silently did nothing.
242
+ # Pinned by test/features/keyboard_editing_test.rb.
243
+ # #3305 (8946) renamed --enable-external-stylesheets to
244
+ # `--load-resources stylesheet` and --log-filter-scopes to
245
+ # --log-filter; build_args now passes the new spelling, which is a
246
+ # fatal UnknownOption below 8946. (The same PR flipped iframes and
247
+ # workers off by default — Browser#configure_loading re-enables them
248
+ # per BrowserContext, and did so before this bump.)
249
+ #
250
+ # 2026-10-03 bump 9058 -> 9994 (= release 1.0.0, the new lockstep cap).
251
+ # Retired or ungated by it:
252
+ # #3375 (9071) option.selected deselects its siblings — SELECT_OPTION_JS
253
+ # dropped its `sel.value =` detour, which selected the FIRST option
254
+ # sharing a value rather than the one Capybara resolved.
255
+ # #3378 (9207) matchMedia `change` fires on a viewport override —
256
+ # viewport_test.rb's pin runs un-gated.
257
+ # #3414 / #3420 / #3422 / #3424 (9217-9234, ours) trusted
258
+ # `beforeinput` is cancelable; `.value =` moves the caret to the end;
259
+ # a child-text <textarea> can be selected; arrows/Home/End move the
260
+ # caret — the `send_keys should send special characters` skip is
261
+ # gone and keyboard_editing_test.rb pins all four.
262
+ # Behavior changes the floor now guarantees, all compensated or
263
+ # Chrome-parity: #3654 (9883) CORS enforced by default (blocked requests
264
+ # end in Network.loadingFailed, which Network consumes); #3529 (9877)
265
+ # SameSite=None without Secure refused (Cookies#set reports it);
266
+ # #3702 (9968) click no longer focuses (CLICK_JS focuses itself).
267
+ MINIMUM_NIGHTLY_BUILD = Gem::Version.new("9994")
205
268
 
206
269
  # Second, equivalent floor for the *release* channel.
207
270
  #
@@ -210,21 +273,66 @@ module Capybara
210
273
  # bare "0.3.6" carrying no commit counter at all, and MINIMUM_NIGHTLY_BUILD
211
274
  # has nothing to compare against. Releases are cut from the same trunk, so
212
275
  # a release is acceptable exactly when its own commit count clears the
213
- # nightly floor. As of 2026-08-25 NO published release clears the 8796
214
- # floor — 0.3.7 (build 8671) predates the draggable-IDL and
215
- # coordinate-flooring fixes the floor exists for — so this pin names the
216
- # NEXT release. 0.3.8 is not tagged yet; any future tag is cut from a
217
- # trunk already past build 8834, so it clears the floor by construction.
218
- # Until upstream tags it, the release channel is deliberately unusable
219
- # (nightly-only), reported with the standard too-old error. When 0.3.8
220
- # ships: verify `git rev-list --count 0.3.8` >= 8796 and update the
221
- # lockstep table in process_test.rb.
276
+ # nightly floor. 1.0.0 (2026-10-02) is build 9994 — exactly the nightly
277
+ # floor, which is no accident: the floor was raised *to* the release so
278
+ # both channels gate the same commit. (0.4.1 = 9463 and 0.4.0 = 9058
279
+ # predate fixes the floor exists for.)
222
280
  #
223
281
  # INVARIANT: every MINIMUM_NIGHTLY_BUILD bump must also move this to the
224
282
  # first release containing that build (`git rev-list --count <tag>` in the
225
283
  # browser repo tells you). Leaving it behind would let the release channel
226
284
  # silently accept a binary the nightly channel rejects.
227
- MINIMUM_RELEASE = Gem::Version.new("0.3.8")
285
+ MINIMUM_RELEASE = Gem::Version.new("1.0.0")
286
+
287
+ class << self
288
+ # `lightpanda version` prints one of two shapes, and the gem supports
289
+ # both so a suite can either track nightly or pin a reproducible
290
+ # release:
291
+ #
292
+ # "1.0.0-nightly.8285+de85a51d" rolling nightly (also "dev.NNNN" for
293
+ # a locally compiled tree — same
294
+ # `git rev-list --count HEAD` counter,
295
+ # different label). Checked against
296
+ # MINIMUM_NIGHTLY_BUILD.
297
+ # "0.3.5" a tagged release: bare semver, no
298
+ # build metadata. Checked against
299
+ # MINIMUM_RELEASE.
300
+ #
301
+ # The release form is matched anchored — a bare semver and nothing else
302
+ # — so a stray "1.2.3" inside some other string can never be mistaken
303
+ # for a release. Anything that matches neither shape stays a hard
304
+ # failure: a version we cannot identify is never assumed to be new
305
+ # enough.
306
+ #
307
+ # Lives on the class, not the instance, because there are two ways to
308
+ # learn a version and only one of them involves a Process: the spawn
309
+ # path shells out to the binary, while an externally-managed browser
310
+ # (`ws_url:`) is asked over CDP. `LP.version` returns the identical
311
+ # string the CLI prints, so both channels share this one parser rather
312
+ # than drifting apart — the drift being how `ws_url:` shipped with no
313
+ # floor check at all.
314
+ #
315
+ # Returns [version, nightly_build, release]; exactly one of the latter
316
+ # two is non-nil. The update hint is yielded rather than passed so the
317
+ # caller's hint-building only runs on the failure path.
318
+ def check_version!(raw)
319
+ version = raw.to_s.strip
320
+
321
+ if (build = version[/(?:nightly|dev)\.(\d+)/, 1])
322
+ nightly_build = Gem::Version.new(build)
323
+ return [version, nightly_build, nil] if nightly_build >= MINIMUM_NIGHTLY_BUILD
324
+ elsif (tag = version[/\A\d+\.\d+\.\d+\z/])
325
+ release = Gem::Version.new(tag)
326
+ return [version, nil, release] if release >= MINIMUM_RELEASE
327
+ end
328
+
329
+ raise BinaryError,
330
+ "Lightpanda #{version} is too old. " \
331
+ "This gem requires nightly build >= #{MINIMUM_NIGHTLY_BUILD} " \
332
+ "or release >= #{MINIMUM_RELEASE}. " \
333
+ "Update: #{yield}"
334
+ end
335
+ end
228
336
 
229
337
  attr_reader :pid, :ws_url, :version, :nightly_build, :release
230
338
 
@@ -273,38 +381,12 @@ module Capybara
273
381
 
274
382
  private
275
383
 
276
- # `lightpanda version` prints one of two shapes, and the gem supports both
277
- # so a suite can either track nightly or pin a reproducible release:
278
- #
279
- # "1.0.0-nightly.8285+de85a51d" rolling nightly (also "dev.NNNN" for a
280
- # locally compiled tree — same
281
- # `git rev-list --count HEAD` counter,
282
- # different label). Checked against
283
- # MINIMUM_NIGHTLY_BUILD.
284
- # "0.3.5" a tagged release: bare semver, no build
285
- # metadata. Checked against MINIMUM_RELEASE.
286
- #
287
- # The release form is matched anchored — a bare semver and nothing else —
288
- # so a stray "1.2.3" inside some other string can never be mistaken for a
289
- # release. Anything that matches neither shape stays a hard failure: a
290
- # binary we cannot identify is never assumed to be new enough.
384
+ # Shells the floor check out to the binary. The ws_url path can't — see
385
+ # Browser#check_remote_version, which feeds the same parser from CDP.
291
386
  def check_minimum_version(binary_path)
292
387
  stdout, = Open3.capture3(binary_path, "version")
293
- @version = stdout.strip
294
-
295
- if (build = @version[/(?:nightly|dev)\.(\d+)/, 1])
296
- @nightly_build = Gem::Version.new(build)
297
- return if @nightly_build >= MINIMUM_NIGHTLY_BUILD
298
- elsif (tag = @version[/\A\d+\.\d+\.\d+\z/])
299
- @release = Gem::Version.new(tag)
300
- return if @release >= MINIMUM_RELEASE
301
- end
302
-
303
- raise BinaryError,
304
- "Lightpanda #{@version} is too old. " \
305
- "This gem requires nightly build >= #{MINIMUM_NIGHTLY_BUILD} " \
306
- "or release >= #{MINIMUM_RELEASE}. " \
307
- "Update: #{Binary.update_hint(binary_path)}"
388
+ @version, @nightly_build, @release =
389
+ self.class.check_version!(stdout) { Binary.update_hint(binary_path) }
308
390
  rescue Errno::ENOENT
309
391
  # Binary not runnable — let attempt_start handle it
310
392
  end
@@ -369,10 +451,6 @@ module Capybara
369
451
  @options.port.to_s,
370
452
  "--log_level",
371
453
  "info",
372
- # External stylesheet fetch (PR #2487, build >= 6353 — enforced by the
373
- # floor). Always on so linked CSS contributes to checkVisibility /
374
- # getComputedStyle; see .claude/rules/lightpanda-io.md limitation #6.
375
- "--enable-external-stylesheets",
376
454
  # Raise the inbound CDP WebSocket message cap from Lightpanda's 1 MiB
377
455
  # default (Config.zig `cdp_max_message_size`) to 100 MiB, matching
378
456
  # Chrome's inbound DevTools buffer. An inbound message over the cap is
@@ -387,12 +465,23 @@ module Capybara
387
465
  "--cdp-max-message-size",
388
466
  (100 * 1024 * 1024).to_s,
389
467
  ]
390
- # Opt-in image fetching (upstream #3230, build >= 8834). Deliberately
391
- # NOT always-on like --enable-external-stylesheets: images never feed
392
- # the DOM predicates, so the default spends no bandwidth on them. On
393
- # builds < 8834 the flag is a fatal UnknownOption at boot — see
394
- # Options#load_images.
395
- base.push("--load-resources", "image") if @options.load_images
468
+ # Sub-resource loading is one comma-separated `--load-resources` value
469
+ # (Config.LoadResources is a packed struct the CLI parser fills from a
470
+ # comma list — the flag is NOT repeatable). Every kind defaults OFF
471
+ # since #3305 (build 8946).
472
+ # stylesheet — always on (PR #2487, build >= 6353) so linked CSS
473
+ # contributes to checkVisibility / getComputedStyle; see
474
+ # .claude/rules/lightpanda-io.md limitation #5. The spelling
475
+ # `--load-resources stylesheet` is a fatal UnknownOption below 8946,
476
+ # which the floor clears.
477
+ # image — opt-in (upstream #3230, build >= 8834): images never feed
478
+ # the DOM predicates, so the default spends no bandwidth on them.
479
+ # See Options#load_images.
480
+ # iframe / worker — deliberately NOT here. Browser#configure_loading
481
+ # re-enables both over CDP (LP.configureLoading) per BrowserContext.
482
+ resources = ["stylesheet"]
483
+ resources << "image" if @options.load_images
484
+ base.push("--load-resources", resources.join(","))
396
485
  extra = ENV.fetch("LIGHTPANDA_EXTRA_ARGS", "").split
397
486
  base + extra
398
487
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Lightpanda
5
- VERSION = "0.11.0"
5
+ VERSION = "0.13.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: capybara-lightpanda
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Navid Emad
@@ -129,7 +129,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
129
129
  - !ruby/object:Gem::Version
130
130
  version: '0'
131
131
  requirements: []
132
- rubygems_version: 4.0.16
132
+ rubygems_version: 4.0.20
133
133
  specification_version: 4
134
134
  summary: Capybara driver for the Lightpanda headless browser
135
135
  test_files: []