capybara-lightpanda 0.1.0 → 0.2.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: c46e211cf39dfb4b533c59ec88106fd9d4c72d59ed8ef11d99af0d9488885f9e
4
- data.tar.gz: 00f22e0a391ccc5c015291d72fa2d53f1d10062c0413f1fb62298a13a7006307
3
+ metadata.gz: 6ffb56a5986bbbe2ecfd36662d9e8bfed778046344f013ec028121100f9f87a5
4
+ data.tar.gz: c9a86b39d5ab675b6e00dfeb22b26a8aaef10b3a4d88e7455ce517452ec1c317
5
5
  SHA512:
6
- metadata.gz: e3d08ffeb41acac97492d9c1cdf0aaca8499487b38afa6f1b8a1598f5c060cead1f0b4d5572f23ffeee0a49afc555f790a6e358c88816e143494619bd1f88128
7
- data.tar.gz: 7a2bfcafc663cba5614bd4385db9957f68d58255f6cfe4ee4a3090a447a5302f2a09db6dc2bcea9ce56795cb2e8739d1f440143e0c8da4d393e7078d76d9b229
6
+ metadata.gz: 883cc590c12520bf5be6c617c106ddf9b5e859384115b768ea2c1b434b4e62ee848354cf171facac43092577aec7ed8aef7dbd43871dc5b5c1415363d26e80e8
7
+ data.tar.gz: fbd160fa34522967f09275f5f9fd5da225f9f2ec365e233576a495be7e29873a221c1d42edf1cef8d585b7972c0813e6b15cb876f6eb1b6879836248144907ab
data/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0] - 2026-05-04
4
+
5
+ Reliability and feature polish as Lightpanda matured. **Update Lightpanda before upgrading**: this release requires a current nightly (the gem will tell you if yours is too old).
6
+
7
+ ### Added
8
+
9
+ - `Driver#wait_for_network_idle(timeout:, connections:)` — wait until in-flight HTTP requests drop to a threshold. Useful for SPAs and pages with deferred XHR.
10
+ - `handshake_timeout` driver option — cap how long the gem waits for the browser process to come up, separate from per-command timeouts.
11
+ - `Cookies` is now `Enumerable` — `cookies.find`, `cookies.select`, etc. work directly.
12
+ - `set_cookie` now infers the domain from the current URL (or `Capybara.app_host`) when you don't pass one.
13
+
14
+ ### Changed
15
+
16
+ - `accept_modal(:confirm)` and `accept_modal(:prompt)` now actually drive the JS return value. Previously they only captured the dialog message; the page-side `confirm()` / `prompt()` always saw the dismiss outcome.
17
+ - `prompt` dialogs now respect the `defaultText` argument when you call `accept_modal(:prompt)` without `with:`.
18
+ - Form interactions are noticeably more reliable on Turbo Drive pages, redirects, and JS-heavy SPAs — many subtle navigation/form-submit edge cases are now handled natively.
19
+ - All transient CDP errors inherit from `BrowserError`. A single `rescue Capybara::Lightpanda::BrowserError` catches the lot.
20
+
21
+ ### Fixed
22
+
23
+ - Capybara's `node #send_keys should generate key events`, `#has_field with valid`, `#fill_in` with range/date/time inputs, `#refresh reposts`, `attach_file` (most cases), `accept_confirm`, label clicks, image-button submits, and `<summary>` clicks all pass against the new nightly floor.
24
+ - A subscription leak in `Page.loadEventFired` after navigation could slowly accumulate listeners on long-running suites.
25
+ - `visibleText` no longer inserts a stray newline between adjacent empty block elements.
26
+ - A clearer error message when `lsof` isn't installed and the gem can't reclaim a stuck port.
27
+
28
+ ### Removed
29
+
30
+ - Most of the gem's JS polyfills — Lightpanda implements these natively now: the `#id` selector rewriter, the form-submission fetch+swap, the label/image-button/summary click handlers, and large parts of the visibility/disabled-state polyfills. No code change required on your end; tests should just pass.
31
+
32
+ ### Internal
33
+
34
+ - 91-case XPath 1.0 conformance battery (replaced ad-hoc specs).
35
+ - README redesign.
36
+
3
37
  ## [0.1.0] - 2026-04-27
4
38
 
5
39
  Initial release. Capybara driver for the [Lightpanda](https://github.com/lightpanda-io/browser) headless browser.
data/README.md CHANGED
@@ -1,200 +1,44 @@
1
- # Capybara::Lightpanda
1
+ <div align="center">
2
2
 
3
- A [Capybara](https://github.com/teamcapybara/capybara) driver for [Lightpanda](https://lightpanda.io/), the fast headless browser built in Zig.
3
+ # Capybara::Lightpanda
4
4
 
5
- This gem provides a **self-contained, production-ready** Capybara driver with a built-in CDP client. No external browser-client gem required — just install and go:
5
+ [![Gem version](https://img.shields.io/gem/v/capybara-lightpanda?logo=rubygems&logoColor=white&label=gem)](https://rubygems.org/gems/capybara-lightpanda)
6
+ [![Total downloads](https://img.shields.io/gem/dt/capybara-lightpanda?label=downloads)](https://rubygems.org/gems/capybara-lightpanda)
7
+ [![Tests](https://img.shields.io/github/actions/workflow/status/navidemad/capybara-lightpanda/ci.yml?branch=main&logo=github&label=tests)](https://github.com/navidemad/capybara-lightpanda/actions/workflows/ci.yml)
8
+ [![Rails compatible](https://img.shields.io/badge/Rails-compatible-CC0000?logo=rubyonrails&logoColor=white)](https://rubyonrails.org/)
9
+ [![Turbo friendly](https://img.shields.io/badge/Turbo-friendly-CC0000?logo=hotwire&logoColor=white)](https://turbo.hotwired.dev/)
6
10
 
7
- - **Reliable navigation** — falls back to `document.readyState` polling when `Page.loadEventFired` doesn't fire (a known Lightpanda limitation on pages with complex JS)
8
- - **XPath polyfill** auto-injected after each navigation so Capybara's internal XPath selectors work (`find`, `click_on`, `fill_in`, `assert_selector`, etc.)
9
- - **Cookie management** — `set_cookie`, `clear_cookies`, `remove_cookie` on the driver + graceful fallback when `Network.clearBrowserCookies` crashes the CDP connection
10
- - **Drop-in Capybara integration** — registers a `:lightpanda` driver, configure and go
11
+ A [Capybara](https://github.com/teamcapybara/capybara) driver for [Lightpanda](https://lightpanda.io/), the fast headless browser built in Zig.<br>
12
+ Self-containedbuilt-in CDP client, no external browser-client gem required.
11
13
 
12
- ## Architecture
14
+ <strong>Capybara</strong>&nbsp;&nbsp;→&nbsp;&nbsp;<code>capybara-lightpanda</code>&nbsp;&nbsp;→&nbsp;&nbsp;<a href="https://lightpanda.io/"><img src="docs/static/img/lightpanda-logo.svg" alt="Lightpanda" height="22" valign="middle"></a>&nbsp;<a href="https://github.com/lightpanda-io/browser/stargazers"><img src="https://img.shields.io/github/stars/lightpanda-io/browser?logo=github&label=stars" alt="GitHub stars" valign="middle"></a>
13
15
 
14
- Similar to how [Cuprite](https://github.com/rubycdp/cuprite) builds on [Ferrum](https://github.com/rubycdp/ferrum), but as a single gem:
16
+ [![Capybara::Lightpanda faster system tests for Rails, without Chromium](docs/static/img/banner.png)](https://navidemad.github.io/capybara-lightpanda/)
17
+ <sub><em>Configuration · dual-driver setups · Turbo Rails · capability matrix · beta-testing guide</em></sub>
15
18
 
16
- ```
17
- Capybara → capybara-lightpanda (driver + CDP client) → Lightpanda browser
18
- ```
19
+ [![Read the docs](https://img.shields.io/badge/https%3A%2F%2Fnavidemad.github.io%2Fcapybara--lightpanda-→%20Visit%20the%20website-1F2937?style=for-the-badge)](https://navidemad.github.io/capybara-lightpanda/)
19
20
 
20
- ## Installation
21
+ </div>
21
22
 
22
- ### 1. Install the Lightpanda browser
23
-
24
- ```bash
25
- # macOS
26
- brew install lightpanda-io/lightpanda/lightpanda
27
-
28
- # Linux (Debian/Ubuntu) — see https://lightpanda.io/docs/
29
- ```
23
+ ## Install
30
24
 
31
- ### 2. Add the gem
25
+ Add this to your `Gemfile` and run `bundle install`:
32
26
 
33
27
  ```ruby
34
- # Gemfile
35
28
  group :test do
36
29
  gem "capybara-lightpanda"
37
30
  end
38
31
  ```
39
32
 
40
- ```bash
41
- bundle install
42
- ```
43
-
44
- ## Usage
45
-
46
- ### Basic setup
33
+ In your test setup:
47
34
 
48
35
  ```ruby
49
- # test/support/capybara.rb or spec/support/capybara.rb
50
36
  require "capybara-lightpanda"
51
-
52
- Capybara::Lightpanda.configure do |config|
53
- config.host = "127.0.0.1"
54
- config.port = 9222
55
- config.timeout = 15
56
- config.browser_path = "/usr/local/bin/lightpanda" # optional, auto-detected
57
- end
58
-
59
- Capybara.default_driver = :lightpanda
60
37
  Capybara.javascript_driver = :lightpanda
61
38
  ```
62
39
 
63
- ### Dual-driver setup (recommended)
64
-
65
- Run most tests with Chrome, use Lightpanda for fast DOM-only tests:
66
-
67
- ```ruby
68
- if ENV["BROWSER"] == "lightpanda"
69
- require "capybara-lightpanda"
70
-
71
- Capybara::Lightpanda.configure do |config|
72
- config.timeout = 15
73
- end
74
-
75
- Capybara.default_driver = :lightpanda
76
- Capybara.javascript_driver = :lightpanda
77
- else
78
- # Your existing Chrome/Cuprite setup
79
- Capybara.default_driver = :cuprite
80
- end
81
- ```
82
-
83
- ```bash
84
- # Run with Lightpanda
85
- BROWSER=lightpanda bundle exec rails test test/system/
86
-
87
- # Run with Chrome (default)
88
- bundle exec rails test test/system/
89
- ```
90
-
91
- ### Setting cookies (e.g. login helper)
92
-
93
- ```ruby
94
- class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
95
- def login_as(user)
96
- session = user.sessions.first_or_create!
97
- cookie_jar = ActionDispatch::TestRequest.create({ "REQUEST_METHOD" => "GET" }).cookie_jar
98
- cookie_jar.signed[:session_id] = { value: session.id }
99
-
100
- page.driver.set_cookie(
101
- "session_id",
102
- cookie_jar[:session_id],
103
- domain: "127.0.0.1",
104
- httpOnly: true,
105
- secure: false,
106
- )
107
- end
108
- end
109
- ```
110
-
111
- ## What works
112
-
113
- - Navigation (`visit`, `click_link`, `go_back`, `go_forward`, `refresh`)
114
- - JavaScript execution (V8 engine) — `evaluate_script`, `execute_script`, `evaluate_async_script`
115
- - Forms — `fill_in`, `click_button`, `select`, `choose`, `check`, `uncheck`
116
- - Finding — `find`, `all`, `within`, CSS and XPath selectors
117
- - Matchers — `assert_selector`, `assert_text`, `assert_current_path`, `has_field?`, `has_select?`
118
- - Cookies — set/get/clear/remove via CDP
119
- - Frames — `within_frame`, scoped finding
120
- - Keyboard — `send_keys` with modifiers and special keys
121
- - Network — traffic tracking, custom headers, idle waiting
122
-
123
- ### Turbo Rails support
124
-
125
- The gem handles Turbo-enabled Rails apps transparently:
126
-
127
- | Feature | Status | How |
128
- |---------|--------|-----|
129
- | **Turbo Frames** | Works natively | Lazy-loading (`src=`), scoped link navigation |
130
- | **Turbo Drive** | Auto-disabled | Gem disables Drive (body replacement fails in Lightpanda) — standard link navigation restored |
131
- | **Form submission** | Auto-handled | When Turbo is present, forms submit via `fetch()` + `document.write()` to bypass Turbo's interception |
132
- | **Turbo Streams** | Not supported | Depends on Turbo's fetch pipeline which Lightpanda can't render |
133
-
134
- **Root cause**: Lightpanda's `document.body` is read-only — Turbo Drive's body replacement and frame form responses can't be applied. The gem works around this automatically.
135
-
136
- ## Known limitations
137
-
138
- These are Lightpanda browser limitations, not driver limitations:
139
-
140
- | Feature | Status |
141
- |---------|--------|
142
- | Screenshots | Not supported (no rendering engine) |
143
- | `window.getComputedStyle()` | Returns defaults (no CSS engine) |
144
- | `scroll_to`, `resize` | No layout engine |
145
- | Complex Stimulus controllers | Some may not execute fully |
146
- | XPath axes/functions | Polyfill covers ~80% of Capybara usage |
147
- | File uploads | Not yet supported |
148
- | Turbo Streams | Not supported (Turbo's fetch-then-render pipeline) |
149
-
150
- ## Benchmark
151
-
152
- Tested on a Rails 8.1 app (Turbo + Stimulus), 24 DOM-only tests:
153
-
154
- | Driver | Tests | Time | Speed |
155
- |--------|-------|------|-------|
156
- | **Lightpanda** | 24/24 pass | 6.89s | 3.48 tests/s |
157
- | **Chrome** | 24/24 pass | 7.09s | 3.38 tests/s |
158
-
159
- Lightpanda's advantage is expected to grow on larger suites due to faster startup and lower memory usage.
160
-
161
- ## Configuration
162
-
163
- ```ruby
164
- Capybara::Lightpanda.configure do |config|
165
- config.host = "127.0.0.1" # Lightpanda bind host
166
- config.port = 9222 # Lightpanda CDP port
167
- config.timeout = 15 # Navigation/command timeout (seconds)
168
- config.process_timeout = 10 # Browser process startup timeout
169
- config.browser_path = nil # Path to lightpanda binary (auto-detected)
170
- end
171
- ```
172
-
173
- ### Dynamic port (parallel tests)
174
-
175
- ```ruby
176
- def available_port
177
- server = TCPServer.new("127.0.0.1", 0)
178
- port = server.addr[1]
179
- server.close
180
- port
181
- end
182
-
183
- Capybara::Lightpanda.configure do |config|
184
- config.port = ENV.fetch("LIGHTPANDA_PORT", available_port).to_i
185
- end
186
- ```
187
-
188
- ## How it works
189
-
190
- | Component | Description |
191
- |-----------|-------------|
192
- | `Browser` | High-level API with readyState polling fallback when `Page.loadEventFired` never fires |
193
- | `Cookies` | Catches `BrowserError` from unsupported `Network.clearBrowserCookies`, deletes cookies individually |
194
- | `XPathPolyfill` | Provides `document.evaluate` + `XPathResult` shim for Capybara's XPath selectors |
195
- | `Client` | CDP command dispatch over WebSocket with timeout and event subscription |
196
- | `Driver` | Complete Capybara driver with `set_cookie`, `clear_cookies`, `remove_cookie` |
197
- | `Node` | DOM interactions via JavaScript evaluation |
40
+ > [!TIP]
41
+ > The Lightpanda binary is auto-downloaded on first use — no separate install step needed.
198
42
 
199
43
  ## Credits
200
44
 
@@ -202,13 +46,12 @@ end
202
46
  - [Capybara](https://github.com/teamcapybara/capybara) — the test framework
203
47
  - Inspired by the [Cuprite](https://github.com/rubycdp/cuprite) / [Ferrum](https://github.com/rubycdp/ferrum) architecture and [`lightpanda-ruby`](https://github.com/marcoroth/lightpanda-ruby)
204
48
 
205
- Patterns adapted from these MIT-licensed projects (cookies API, frame switching,
206
- node call/error conventions, retry/event utilities) are acknowledged with the
207
- original copyright notices in [NOTICE.md](NOTICE.md).
49
+ Patterns adapted from these MIT-licensed projects (cookies API, frame switching, node call/error conventions, retry/event utilities) are acknowledged with the original copyright notices in [NOTICE.md](NOTICE.md).
208
50
 
209
51
  ## Contributing
210
52
 
211
- Bug reports and pull requests are welcome on [GitHub](https://github.com/navidemad/capybara-lightpanda).
53
+ Bug reports and pull requests are welcome on [GitHub](https://github.com/navidemad/capybara-lightpanda).<br>
54
+ For beta-testing tips and how to file useful feedback, see [BETA_TESTING.md](BETA_TESTING.md).
212
55
 
213
56
  ## License
214
57
 
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "forwardable"
4
- require "uri"
5
4
  require "concurrent-ruby"
6
5
 
7
6
  module Capybara
@@ -32,25 +31,16 @@ module Capybara
32
31
  @session_id = nil
33
32
  @started = false
34
33
  @page_events_enabled = false
35
- @modal_responses = []
36
34
  @modal_messages = []
37
35
  @modal_handler_installed = false
38
36
  @frame_stack = []
39
37
  @frames = Concurrent::Hash.new
40
38
  @turbo_event = Utils::Event.new
41
39
  @turbo_event.set
42
- @visited_origins = Concurrent::Set.new
43
40
 
44
41
  start
45
42
  end
46
43
 
47
- # Set of `scheme://host:port` strings the browser has navigated to during
48
- # this session. Used by Cookies#clear to enumerate cookies across all
49
- # domains: Lightpanda's `Network.getCookies` (no urls param) is scoped
50
- # to the current page's origin, so without tracked origins we'd miss
51
- # cookies set on previously-visited domains.
52
- attr_reader :visited_origins
53
-
54
44
  def start
55
45
  return if @started
56
46
 
@@ -150,8 +140,6 @@ module Capybara
150
140
  else
151
141
  page_command("Page.navigate", url: url)
152
142
  end
153
-
154
- record_visited_origin(url)
155
143
  end
156
144
  alias goto go_to
157
145
 
@@ -172,13 +160,14 @@ module Capybara
172
160
  # Run the block; if it raises NoExecutionContextError (the navigation
173
161
  # race window — lightpanda-io/browser#2187), wait for the next default
174
162
  # context to be signaled by Runtime.executionContextCreated, then
175
- # retry once. Replaces blind 100 ms sleep retries.
176
- def with_default_context_wait(timeout: 1.0)
177
- yield
178
- rescue NoExecutionContextError
179
- raise unless wait_for_default_context(timeout)
180
-
181
- yield
163
+ # retry. Up to `attempts` total tries; defaults to 3, can be bumped
164
+ # for stubborn flakes. Each retry blocks up to `timeout` seconds for
165
+ # the executionContextCreated signal — no blind sleeps.
166
+ def with_default_context_wait(timeout: 1.0, attempts: 3)
167
+ Utils::Attempt.with_retry(errors: NoExecutionContextError, max: attempts, wait: 0) do
168
+ wait_for_default_context(timeout)
169
+ yield
170
+ end
182
171
  end
183
172
 
184
173
  def back
@@ -296,7 +285,7 @@ module Capybara
296
285
  # Release a remote object reference to free V8 memory.
297
286
  def release_object(remote_object_id)
298
287
  page_command("Runtime.releaseObject", objectId: remote_object_id)
299
- rescue BrowserError, NoExecutionContextError
288
+ rescue BrowserError
300
289
  # Object may already be released or context destroyed
301
290
  end
302
291
 
@@ -472,14 +461,14 @@ module Capybara
472
461
  end
473
462
 
474
463
  # -- Modal/Dialog Support --
475
- # Lightpanda auto-dismisses dialogs in headless mode: alert→OK,
476
- # confirm→false, prompt→null. Page.javascriptDialogOpening fires
477
- # (since 2026-04-03), so we capture messages for find_modal, but
478
- # Page.handleJavaScriptDialog always errors with "No dialog is showing"
479
- # and we never call it (the dispatch thread cannot make synchronous
480
- # CDP calls without deadlocking). @modal_responses is retained so
481
- # accept_modal/dismiss_modal preserve their API contract; the
482
- # accept/dismiss choice is informational only.
464
+ # Lightpanda's JS dialogs (alert/confirm/prompt) are driven via the
465
+ # `LP.handleJavaScriptDialog` pre-arm model (PR #2261, nightly ≥5900):
466
+ # the client sends `LP.handleJavaScriptDialog {accept, promptText}`
467
+ # BEFORE the action that triggers the dialog, and the response is
468
+ # consumed when the dialog opens. `Page.javascriptDialogOpening` still
469
+ # fires, so we capture the message text for `find_modal`. Single-shot:
470
+ # `pending_dialog_response` is one slot, so a second pre-arm before
471
+ # the first dialog opens overwrites the first.
483
472
 
484
473
  def prepare_modals
485
474
  return if @modal_handler_installed
@@ -488,20 +477,21 @@ module Capybara
488
477
 
489
478
  on("Page.javascriptDialogOpening") do |params|
490
479
  @modal_messages << { type: params["type"], message: params["message"] }
491
- @modal_responses.shift
492
480
  end
493
481
 
494
482
  @modal_handler_installed = true
495
483
  end
496
484
 
497
- def accept_modal(type, text: nil)
485
+ def accept_modal(_type, text: nil)
498
486
  prepare_modals
499
- @modal_responses << { accept: true, text: text, type: type.to_s }
487
+ params = { accept: true }
488
+ params[:promptText] = text if text
489
+ page_command("LP.handleJavaScriptDialog", **params)
500
490
  end
501
491
 
502
- def dismiss_modal(type)
492
+ def dismiss_modal(_type)
503
493
  prepare_modals
504
- @modal_responses << { accept: false, type: type.to_s }
494
+ page_command("LP.handleJavaScriptDialog", accept: false)
505
495
  end
506
496
 
507
497
  def find_modal(type, text: nil, wait: options.timeout)
@@ -525,7 +515,6 @@ module Capybara
525
515
  end
526
516
 
527
517
  def reset_modals
528
- @modal_responses.clear
529
518
  @modal_messages.clear
530
519
  end
531
520
 
@@ -811,23 +800,9 @@ module Capybara
811
800
  end
812
801
 
813
802
  def wait_for_page_load(url, retried:)
814
- starting_url = safe_current_url
815
- deadline = monotonic_time + @options.timeout
816
- loaded = Utils::Event.new
817
-
818
- handler = proc { loaded.set }
819
- @client.on("Page.loadEventFired", &handler)
820
-
821
- @client.command("Page.navigate", { url: url }, async: true, session_id: @session_id)
822
-
823
- # Give loadEventFired a brief window (fast path), then fall back
824
- # to readyState polling with the remaining budget.
825
- unless loaded.wait([2, @options.timeout].min)
826
- remaining = deadline - monotonic_time
827
- poll_ready_state(remaining, loaded_event: loaded, starting_url: starting_url) if remaining.positive?
803
+ deadline = await_navigation do
804
+ @client.command("Page.navigate", { url: url }, async: true, session_id: @session_id)
828
805
  end
829
-
830
- @client.off("Page.loadEventFired", handler)
831
806
  handle_navigation_crash(url, deadline, retried: retried)
832
807
  end
833
808
 
@@ -883,23 +858,37 @@ module Capybara
883
858
 
884
859
  # Wait for a navigation triggered by the given block.
885
860
  # Uses the same loadEventFired + readyState fallback as go_to.
886
- def wait_for_navigation
861
+ def wait_for_navigation(&)
887
862
  enable_page_events
863
+ await_navigation(&)
864
+ end
888
865
 
866
+ # Common navigation lifecycle shared by `wait_for_page_load` (fresh
867
+ # `Page.navigate`) and `wait_for_navigation` (back / forward / reload).
868
+ # Subscribes to Page.loadEventFired, runs the trigger, waits briefly for
869
+ # the event, falls back to readyState polling for the remaining budget.
870
+ # The handler is unsubscribed via `ensure` so a raising trigger doesn't
871
+ # leak a subscription onto the next navigation. Returns the deadline so
872
+ # the caller can decide whether to attempt crash recovery.
873
+ def await_navigation
889
874
  starting_url = safe_current_url
890
875
  deadline = monotonic_time + @options.timeout
891
876
  loaded = Utils::Event.new
892
877
  handler = proc { loaded.set }
893
878
  @client.on("Page.loadEventFired", &handler)
894
879
 
895
- yield
880
+ begin
881
+ yield
896
882
 
897
- unless loaded.wait([2, @options.timeout].min)
898
- remaining = deadline - monotonic_time
899
- poll_ready_state(remaining, loaded_event: loaded, starting_url: starting_url) if remaining.positive?
883
+ unless loaded.wait([2, @options.timeout].min)
884
+ remaining = deadline - monotonic_time
885
+ poll_ready_state(remaining, loaded_event: loaded, starting_url: starting_url) if remaining.positive?
886
+ end
887
+ ensure
888
+ @client.off("Page.loadEventFired", handler)
900
889
  end
901
890
 
902
- @client.off("Page.loadEventFired", handler)
891
+ deadline
903
892
  end
904
893
 
905
894
  # Poll document.readyState as a fallback when Page.loadEventFired
@@ -945,19 +934,6 @@ module Capybara
945
934
  def monotonic_time
946
935
  ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
947
936
  end
948
-
949
- # Capture `scheme://host:port` from a navigated URL so Cookies#clear can
950
- # enumerate cookies across all visited domains. Skips opaque URLs
951
- # (about:blank, data:, etc.) and any URI parser failure.
952
- def record_visited_origin(url)
953
- uri = URI.parse(url)
954
- return unless uri.scheme && uri.host
955
-
956
- port = uri.port || (uri.scheme == "https" ? 443 : 80)
957
- @visited_origins << "#{uri.scheme}://#{uri.host}:#{port}"
958
- rescue URI::InvalidURIError, NoMethodError
959
- nil
960
- end
961
937
  end
962
938
  end
963
939
  end
@@ -133,7 +133,7 @@ module Capybara
133
133
  def read_handshake_response
134
134
  started_at = Time.now
135
135
 
136
- while @status != :open && Time.now - started_at < @options.timeout
136
+ while @status != :open && Time.now - started_at < @options.handshake_timeout
137
137
  next unless @socket.wait_readable(0.1)
138
138
 
139
139
  begin
@@ -144,7 +144,9 @@ module Capybara
144
144
  end
145
145
  end
146
146
 
147
- raise TimeoutError, "WebSocket connection timeout" unless @status == :open
147
+ return if @status == :open
148
+
149
+ raise TimeoutError, "WebSocket handshake timed out after #{@options.handshake_timeout}s"
148
150
  end
149
151
 
150
152
  def parse_message(data)
@@ -5,6 +5,8 @@ require "yaml"
5
5
  module Capybara
6
6
  module Lightpanda
7
7
  class Cookies
8
+ include Enumerable
9
+
8
10
  # Typed wrapper around a CDP cookie hash so callers don't have to remember
9
11
  # the camelCase keys (`httpOnly`, `sameSite`, …) the CDP returns. Mirrors
10
12
  # ferrum's Cookies::Cookie. `attributes` exposes the raw hash for callers
@@ -58,12 +60,21 @@ module Capybara
58
60
  end
59
61
 
60
62
  def all
61
- result = browser.command("Network.getCookies")
63
+ result = browser.command("Network.getAllCookies")
62
64
  (result["cookies"] || []).map { |c| Cookie.new(c) }
63
65
  end
64
66
 
67
+ # Yields each Cookie. Powers `Enumerable` (so callers can do
68
+ # `cookies.find { … }`, `cookies.select { … }`, `cookies.to_a`, …
69
+ # without going through `all` first).
70
+ def each(&block)
71
+ return enum_for(:each) unless block
72
+
73
+ all.each(&block)
74
+ end
75
+
65
76
  def get(name)
66
- all.find { |cookie| cookie.name == name }
77
+ find { |cookie| cookie.name == name }
67
78
  end
68
79
 
69
80
  def set(name:, value:, domain: nil, path: "/", secure: false, http_only: false, expires: nil)
@@ -88,29 +99,8 @@ module Capybara
88
99
  browser.command("Network.deleteCookies", **params)
89
100
  end
90
101
 
91
- # Lightpanda gotchas observed on current nightly:
92
- # * `Network.clearBrowserCookies` raises `InvalidParams` (so it does NOT
93
- # clear anything despite the upstream PR #1821 / >= v0.2.6 note).
94
- # * `Network.getCookies` (no `urls` param) is scoped to the CURRENT
95
- # page's origin — cookies set on previously-visited domains are
96
- # invisible from a different page.
97
- # * `Network.getCookies` on `about:blank` raises `InvalidDomain`.
98
- #
99
- # To honor Capybara's `reset_session! removes ALL cookies` contract
100
- # across multiple test domains (e.g. `localhost` AND `127.0.0.1`), we
101
- # iterate every origin Browser has navigated to and per-origin call
102
- # `Network.getCookies(urls: [origin])` then `Network.deleteCookies(url:)`.
103
- # The bulk-clear call is still attempted first as a fast path / future-
104
- # proofing for when upstream fixes it.
105
102
  def clear
106
- begin
107
- browser.command("Network.clearBrowserCookies")
108
- rescue BrowserError, TimeoutError, StandardError
109
- # InvalidParams on current nightly; pre-v0.2.6 used to crash the
110
- # WebSocket. Either way, fall through to per-origin sweep.
111
- end
112
-
113
- sweep_visited_origins
103
+ browser.command("Network.clearBrowserCookies")
114
104
  end
115
105
 
116
106
  # Persist all current cookies to a YAML file (ferrum parity).
@@ -131,35 +121,6 @@ module Capybara
131
121
 
132
122
  private
133
123
 
134
- def sweep_visited_origins
135
- origins = browser.visited_origins.to_a
136
- return if origins.empty?
137
-
138
- result = browser.command("Network.getCookies", urls: origins)
139
- cookies = result["cookies"] || []
140
- cookies.each do |cookie|
141
- # CDP needs either domain or url; build a url from the cookie's
142
- # own domain+path so we don't mismatch (e.g. cookie domain `.x.test`
143
- # against an origin we tracked as `https://x.test:443`).
144
- url = cookie_url(cookie)
145
- params = { name: cookie["name"] }
146
- params[:url] = url if url
147
- params[:domain] = cookie["domain"] unless url
148
- browser.command("Network.deleteCookies", **params)
149
- end
150
- rescue StandardError
151
- # Connection lost or origin no longer valid; nothing more to do.
152
- end
153
-
154
- def cookie_url(cookie)
155
- domain = cookie["domain"].to_s.sub(/\A\./, "")
156
- return nil if domain.empty?
157
-
158
- scheme = cookie["secure"] ? "https" : "http"
159
- path = cookie["path"] || "/"
160
- "#{scheme}://#{domain}#{path}"
161
- end
162
-
163
124
  # set() takes keyword args, but YAML round-trips give us a hash with the
164
125
  # raw CDP keys (camelCase). Normalize and forward.
165
126
  def restore_cookie(hash)