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 +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +22 -179
- data/lib/capybara/lightpanda/browser.rb +45 -69
- data/lib/capybara/lightpanda/client/web_socket.rb +4 -2
- data/lib/capybara/lightpanda/cookies.rb +14 -53
- data/lib/capybara/lightpanda/driver.rb +39 -2
- data/lib/capybara/lightpanda/errors.rb +33 -15
- data/lib/capybara/lightpanda/javascripts/index.js +33 -143
- data/lib/capybara/lightpanda/keyboard.rb +45 -4
- data/lib/capybara/lightpanda/node.rb +22 -217
- data/lib/capybara/lightpanda/options.rb +9 -1
- data/lib/capybara/lightpanda/process.rb +52 -13
- data/lib/capybara/lightpanda/utils/attempt.rb +30 -0
- data/lib/capybara/lightpanda/version.rb +1 -1
- data/lib/capybara-lightpanda.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6ffb56a5986bbbe2ecfd36662d9e8bfed778046344f013ec028121100f9f87a5
|
|
4
|
+
data.tar.gz: c9a86b39d5ab675b6e00dfeb22b26a8aaef10b3a4d88e7455ce517452ec1c317
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# Capybara::Lightpanda
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://rubygems.org/gems/capybara-lightpanda)
|
|
6
|
+
[](https://rubygems.org/gems/capybara-lightpanda)
|
|
7
|
+
[](https://github.com/navidemad/capybara-lightpanda/actions/workflows/ci.yml)
|
|
8
|
+
[](https://rubyonrails.org/)
|
|
9
|
+
[](https://turbo.hotwired.dev/)
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
-
|
|
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-contained — built-in CDP client, no external browser-client gem required.
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
<strong>Capybara</strong> → <code>capybara-lightpanda</code> → <a href="https://lightpanda.io/"><img src="docs/static/img/lightpanda-logo.svg" alt="Lightpanda" height="22" valign="middle"></a> <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
|
-
|
|
16
|
+
[](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
|
+
[](https://navidemad.github.io/capybara-lightpanda/)
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
</div>
|
|
21
22
|
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
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
|
|
476
|
-
#
|
|
477
|
-
#
|
|
478
|
-
#
|
|
479
|
-
#
|
|
480
|
-
#
|
|
481
|
-
#
|
|
482
|
-
#
|
|
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(
|
|
485
|
+
def accept_modal(_type, text: nil)
|
|
498
486
|
prepare_modals
|
|
499
|
-
|
|
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(
|
|
492
|
+
def dismiss_modal(_type)
|
|
503
493
|
prepare_modals
|
|
504
|
-
|
|
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
|
-
|
|
815
|
-
|
|
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
|
-
|
|
880
|
+
begin
|
|
881
|
+
yield
|
|
896
882
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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)
|