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 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