capybara-lightpanda 0.1.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 +7 -0
- data/CHANGELOG.md +50 -0
- data/LICENSE.txt +27 -0
- data/NOTICE.md +101 -0
- data/README.md +215 -0
- data/lib/capybara/lightpanda/binary.rb +190 -0
- data/lib/capybara/lightpanda/browser.rb +963 -0
- data/lib/capybara/lightpanda/client/subscriber.rb +44 -0
- data/lib/capybara/lightpanda/client/web_socket.rb +160 -0
- data/lib/capybara/lightpanda/client.rb +124 -0
- data/lib/capybara/lightpanda/cookies.rb +181 -0
- data/lib/capybara/lightpanda/driver.rb +252 -0
- data/lib/capybara/lightpanda/errors.rb +76 -0
- data/lib/capybara/lightpanda/frame.rb +33 -0
- data/lib/capybara/lightpanda/javascripts/index.js +1108 -0
- data/lib/capybara/lightpanda/keyboard.rb +142 -0
- data/lib/capybara/lightpanda/logger.rb +37 -0
- data/lib/capybara/lightpanda/network.rb +92 -0
- data/lib/capybara/lightpanda/node.rb +726 -0
- data/lib/capybara/lightpanda/options.rb +63 -0
- data/lib/capybara/lightpanda/process.rb +252 -0
- data/lib/capybara/lightpanda/utils/event.rb +37 -0
- data/lib/capybara/lightpanda/version.rb +7 -0
- data/lib/capybara/lightpanda/xpath_polyfill.rb +10 -0
- data/lib/capybara-lightpanda.rb +42 -0
- metadata +119 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c46e211cf39dfb4b533c59ec88106fd9d4c72d59ed8ef11d99af0d9488885f9e
|
|
4
|
+
data.tar.gz: 00f22e0a391ccc5c015291d72fa2d53f1d10062c0413f1fb62298a13a7006307
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e3d08ffeb41acac97492d9c1cdf0aaca8499487b38afa6f1b8a1598f5c060cead1f0b4d5572f23ffeee0a49afc555f790a6e358c88816e143494619bd1f88128
|
|
7
|
+
data.tar.gz: 7a2bfcafc663cba5614bd4385db9957f68d58255f6cfe4ee4a3090a447a5302f2a09db6dc2bcea9ce56795cb2e8739d1f440143e0c8da4d393e7078d76d9b229
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-04-27
|
|
4
|
+
|
|
5
|
+
Initial release. Capybara driver for the [Lightpanda](https://github.com/lightpanda-io/browser) headless browser.
|
|
6
|
+
|
|
7
|
+
### Driver
|
|
8
|
+
|
|
9
|
+
- Capybara driver registered as `:lightpanda`
|
|
10
|
+
- Auto-downloads the Lightpanda binary on first use; binary version exposed via `Browser#binary_version`
|
|
11
|
+
- Reliable navigation: `Page.loadEventFired` + `document.readyState` polling fallback
|
|
12
|
+
- Crash recovery: detects WebSocket disconnects on heavy SPAs and reconnects transparently
|
|
13
|
+
|
|
14
|
+
### CDP client (no external browser-client gem)
|
|
15
|
+
|
|
16
|
+
- WebSocket transport, command dispatch, process management — all in-gem
|
|
17
|
+
- `Capybara::Lightpanda::Utils::Event` (iteration-counted `Concurrent::Event` wrapper, Ferrum-style)
|
|
18
|
+
|
|
19
|
+
### Nodes
|
|
20
|
+
|
|
21
|
+
- Identity via CDP remote object IDs (`Runtime.callFunctionOn`)
|
|
22
|
+
- `Node#[]` resolves URLs for `src`/`href`/`action` and returns live property values for boolean attributes
|
|
23
|
+
- `Node#rect`, `Node#obscured?`, `Node#shadow_root`, `Node#moving?`, `Node#wait_for_stop_moving`
|
|
24
|
+
- Whitespace-normalized `Node#text` / `#all_text` (works around Lightpanda's `textContent` divergence from Chrome)
|
|
25
|
+
|
|
26
|
+
### JavaScript polyfills (auto-injected via `Page.addScriptToEvaluateOnNewDocument`)
|
|
27
|
+
|
|
28
|
+
- XPath 1.0 evaluator (`document.evaluate` + `XPathResult` shim — Lightpanda doesn't implement XPath natively)
|
|
29
|
+
- `#id` selector rewriter for `querySelector{,All}` (Turbo Drive snapshot+swap workaround)
|
|
30
|
+
- `requestSubmit` polyfill
|
|
31
|
+
- Turbo activity tracking sentinels for event-driven `wait_for_turbo` / `wait_for_idle`
|
|
32
|
+
- `fetch()` + body-swap submit pipeline (works around Lightpanda's no-op `form.submit()`)
|
|
33
|
+
|
|
34
|
+
### Cookies
|
|
35
|
+
|
|
36
|
+
- Typed `Cookie` wrapper (Ferrum-style: `name`, `value`, `domain`, `httponly?`, `secure?`, `same_site`, `expires`)
|
|
37
|
+
- `Cookies#store` / `Cookies#load` — YAML round-trip
|
|
38
|
+
- Cross-origin `Cookies#clear` sweep via `visited_origins` tracking (works around `Network.clearBrowserCookies` returning `InvalidParams` on current Lightpanda nightly)
|
|
39
|
+
|
|
40
|
+
### Frames & modals
|
|
41
|
+
|
|
42
|
+
- Frame switching via `contentDocument` scoping; XPath polyfill inherited
|
|
43
|
+
- Frame metadata view populated from CDP frame events (`Frame#parent_id`, etc.)
|
|
44
|
+
- Modal capture via `Page.javascriptDialogOpening`. `accept_modal(:alert)` and `dismiss_modal(:confirm|:prompt)` work; `accept_modal(:confirm|:prompt)` cannot override Lightpanda's auto-dismiss
|
|
45
|
+
|
|
46
|
+
### Tested against
|
|
47
|
+
|
|
48
|
+
- Capybara `>= 3.0, < 5` — runs Capybara's shared spec suite
|
|
49
|
+
- Ruby 3.3 and 4.0
|
|
50
|
+
- Lightpanda nightly (verified against `1.0.0-nightly.5812+b3257754`, 2026-04-26)
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Navid Emad
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
Portions of this gem adapt patterns from Ferrum, Cuprite, and lightpanda-ruby,
|
|
26
|
+
all distributed under the MIT License. See NOTICE.md for the original copyright
|
|
27
|
+
notices and license terms.
|
data/NOTICE.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Third-Party Notices
|
|
2
|
+
|
|
3
|
+
`capybara-lightpanda` adapts patterns and conventions from the following
|
|
4
|
+
MIT-licensed projects. Their copyright notices and license terms are reproduced
|
|
5
|
+
below in accordance with the MIT License.
|
|
6
|
+
|
|
7
|
+
No verbatim source files from these projects are included; the work referenced
|
|
8
|
+
here covers API shape, error-handling conventions, and architectural patterns
|
|
9
|
+
re-implemented for Lightpanda's CDP surface (notably in `Cookies`, `Frame`,
|
|
10
|
+
`Node`, `Driver`, and `Utils::Event`). Code-level pointers to the inspiring
|
|
11
|
+
upstream patterns are kept as comments in the relevant files.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Ferrum
|
|
16
|
+
|
|
17
|
+
<https://github.com/rubycdp/ferrum>
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
MIT License
|
|
21
|
+
|
|
22
|
+
Copyright (c) 2019-2026 Dmitry Vorotilin
|
|
23
|
+
|
|
24
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
25
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
26
|
+
in the Software without restriction, including without limitation the rights
|
|
27
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
28
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
29
|
+
furnished to do so, subject to the following conditions:
|
|
30
|
+
|
|
31
|
+
The above copyright notice and this permission notice shall be included in all
|
|
32
|
+
copies or substantial portions of the Software.
|
|
33
|
+
|
|
34
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
35
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
36
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
37
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
38
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
39
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
40
|
+
SOFTWARE.
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Cuprite
|
|
46
|
+
|
|
47
|
+
<https://github.com/rubycdp/cuprite>
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
MIT License
|
|
51
|
+
|
|
52
|
+
Copyright (c) 2018-2022 Dmitry Vorotilin
|
|
53
|
+
|
|
54
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
55
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
56
|
+
in the Software without restriction, including without limitation the rights
|
|
57
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
58
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
59
|
+
furnished to do so, subject to the following conditions:
|
|
60
|
+
|
|
61
|
+
The above copyright notice and this permission notice shall be included in all
|
|
62
|
+
copies or substantial portions of the Software.
|
|
63
|
+
|
|
64
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
65
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
66
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
67
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
68
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
69
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
70
|
+
SOFTWARE.
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## lightpanda-ruby
|
|
76
|
+
|
|
77
|
+
<https://github.com/marcoroth/lightpanda-ruby>
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
The MIT License (MIT)
|
|
81
|
+
|
|
82
|
+
Copyright (c) 2025 Marco Roth
|
|
83
|
+
|
|
84
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
85
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
86
|
+
in the Software without restriction, including without limitation the rights
|
|
87
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
88
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
89
|
+
furnished to do so, subject to the following conditions:
|
|
90
|
+
|
|
91
|
+
The above copyright notice and this permission notice shall be included in
|
|
92
|
+
all copies or substantial portions of the Software.
|
|
93
|
+
|
|
94
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
95
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
96
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
97
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
98
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
99
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
100
|
+
THE SOFTWARE.
|
|
101
|
+
```
|
data/README.md
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# Capybara::Lightpanda
|
|
2
|
+
|
|
3
|
+
A [Capybara](https://github.com/teamcapybara/capybara) driver for [Lightpanda](https://lightpanda.io/), the fast headless browser built in Zig.
|
|
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:
|
|
6
|
+
|
|
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
|
+
|
|
12
|
+
## Architecture
|
|
13
|
+
|
|
14
|
+
Similar to how [Cuprite](https://github.com/rubycdp/cuprite) builds on [Ferrum](https://github.com/rubycdp/ferrum), but as a single gem:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
Capybara → capybara-lightpanda (driver + CDP client) → Lightpanda browser
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
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
|
+
```
|
|
30
|
+
|
|
31
|
+
### 2. Add the gem
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
# Gemfile
|
|
35
|
+
group :test do
|
|
36
|
+
gem "capybara-lightpanda"
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
bundle install
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
### Basic setup
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# test/support/capybara.rb or spec/support/capybara.rb
|
|
50
|
+
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
|
+
Capybara.javascript_driver = :lightpanda
|
|
61
|
+
```
|
|
62
|
+
|
|
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 |
|
|
198
|
+
|
|
199
|
+
## Credits
|
|
200
|
+
|
|
201
|
+
- [Lightpanda](https://lightpanda.io/) — the headless browser
|
|
202
|
+
- [Capybara](https://github.com/teamcapybara/capybara) — the test framework
|
|
203
|
+
- 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
|
+
|
|
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).
|
|
208
|
+
|
|
209
|
+
## Contributing
|
|
210
|
+
|
|
211
|
+
Bug reports and pull requests are welcome on [GitHub](https://github.com/navidemad/capybara-lightpanda).
|
|
212
|
+
|
|
213
|
+
## License
|
|
214
|
+
|
|
215
|
+
[MIT License](LICENSE.txt)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "open3"
|
|
6
|
+
require "rbconfig"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module Capybara
|
|
10
|
+
module Lightpanda
|
|
11
|
+
class Binary
|
|
12
|
+
Result = Struct.new(:stdout, :stderr, :status) do
|
|
13
|
+
def success?
|
|
14
|
+
status.success?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def exit_code
|
|
18
|
+
status.exitstatus
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def output
|
|
22
|
+
stdout.empty? ? stderr : stdout
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
GITHUB_RELEASE_URL = "https://github.com/lightpanda-io/browser/releases/download/nightly"
|
|
27
|
+
|
|
28
|
+
PLATFORMS = {
|
|
29
|
+
%w[x86_64 linux] => "lightpanda-x86_64-linux",
|
|
30
|
+
%w[aarch64 darwin] => "lightpanda-aarch64-macos",
|
|
31
|
+
%w[arm64 darwin] => "lightpanda-aarch64-macos",
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
def path
|
|
36
|
+
@path ||= find_or_download
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def find_or_download
|
|
40
|
+
find || download
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Always return the nightly binary, downloading if missing or stale.
|
|
44
|
+
# Skips PATH lookup so the system binary is never used.
|
|
45
|
+
def ensure_nightly(max_age: 86_400)
|
|
46
|
+
path = default_binary_path
|
|
47
|
+
download if !File.executable?(path) || (Time.now - File.mtime(path)) > max_age
|
|
48
|
+
path
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def run(*)
|
|
52
|
+
stdout, stderr, status = Open3.capture3(path, *)
|
|
53
|
+
|
|
54
|
+
Result.new(stdout: stdout, stderr: stderr, status: status)
|
|
55
|
+
rescue Errno::ENOENT
|
|
56
|
+
raise BinaryNotFoundError, "Lightpanda binary not found"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def exec(*)
|
|
60
|
+
Kernel.exec(path, *)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def fetch(url)
|
|
64
|
+
result = run("fetch", "--dump", url)
|
|
65
|
+
raise BinaryError, result.stderr unless result.success?
|
|
66
|
+
|
|
67
|
+
result.stdout
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def version
|
|
71
|
+
result = run("version")
|
|
72
|
+
result.output.strip
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def find
|
|
76
|
+
env_path = ENV.fetch("LIGHTPANDA_PATH", nil)
|
|
77
|
+
return env_path if env_path && File.executable?(env_path)
|
|
78
|
+
|
|
79
|
+
path_binary = find_in_path
|
|
80
|
+
return path_binary if path_binary
|
|
81
|
+
|
|
82
|
+
default_path = default_binary_path
|
|
83
|
+
return default_path if File.executable?(default_path)
|
|
84
|
+
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def download
|
|
89
|
+
binary_name = platform_binary
|
|
90
|
+
url = "#{GITHUB_RELEASE_URL}/#{binary_name}"
|
|
91
|
+
destination = default_binary_path
|
|
92
|
+
|
|
93
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
94
|
+
|
|
95
|
+
download_file(url, destination)
|
|
96
|
+
FileUtils.chmod(0o755, destination)
|
|
97
|
+
|
|
98
|
+
destination
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def platform_binary
|
|
102
|
+
arch = normalize_arch(RbConfig::CONFIG["host_cpu"])
|
|
103
|
+
os = normalize_os(RbConfig::CONFIG["host_os"])
|
|
104
|
+
|
|
105
|
+
PLATFORMS[[arch, os]] || raise(UnsupportedPlatformError, "Unsupported platform: #{arch}-#{os}")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def default_binary_path
|
|
109
|
+
cache_dir = ENV.fetch("XDG_CACHE_HOME") { File.expand_path("~/.cache") }
|
|
110
|
+
|
|
111
|
+
File.join(cache_dir, "lightpanda", "lightpanda")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def find_in_path
|
|
117
|
+
ENV["PATH"].to_s.split(File::PATH_SEPARATOR).each do |dir|
|
|
118
|
+
path = File.join(dir, "lightpanda")
|
|
119
|
+
|
|
120
|
+
return path if File.executable?(path) && native_binary?(path)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def native_binary?(path)
|
|
127
|
+
header = File.binread(path, 4)
|
|
128
|
+
|
|
129
|
+
return true if elf_binary?(header)
|
|
130
|
+
return true if mach_o_binary?(header)
|
|
131
|
+
|
|
132
|
+
false
|
|
133
|
+
rescue StandardError
|
|
134
|
+
false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def elf_binary?(header)
|
|
138
|
+
header.start_with?("\x7FELF")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def mach_o_binary?(header)
|
|
142
|
+
header.start_with?("\xCF\xFA\xED\xFE")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def normalize_arch(arch)
|
|
146
|
+
case arch
|
|
147
|
+
when /x86_64|amd64/i then "x86_64"
|
|
148
|
+
when /aarch64|arm64/i then "aarch64"
|
|
149
|
+
else arch
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def normalize_os(os)
|
|
154
|
+
case os
|
|
155
|
+
when /darwin|mac/i then "darwin"
|
|
156
|
+
when /linux/i then "linux"
|
|
157
|
+
else os
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def download_file(url, destination)
|
|
162
|
+
uri = URI.parse(url)
|
|
163
|
+
|
|
164
|
+
follow_redirects(uri, destination)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def follow_redirects(uri, destination, limit = 10)
|
|
168
|
+
raise BinaryNotFoundError, "Too many redirects" if limit.zero?
|
|
169
|
+
|
|
170
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
|
|
171
|
+
request = Net::HTTP::Get.new(uri)
|
|
172
|
+
|
|
173
|
+
http.request(request) do |response|
|
|
174
|
+
case response
|
|
175
|
+
when Net::HTTPSuccess
|
|
176
|
+
File.open(destination, "wb") do |file|
|
|
177
|
+
response.read_body { |chunk| file.write(chunk) }
|
|
178
|
+
end
|
|
179
|
+
when Net::HTTPRedirection
|
|
180
|
+
follow_redirects(URI.parse(response["location"]), destination, limit - 1)
|
|
181
|
+
else
|
|
182
|
+
raise BinaryNotFoundError, "Failed to download binary: #{response.code} #{response.message}"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|