capybara-lightpanda 0.3.0 → 0.4.1

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: 9bb84252bec4e22492ad1e2165a63842c3fad60ab9d80ee0df60db55c2425c9a
4
- data.tar.gz: '090fbdca94bf7e482dda301a2aad4b51065211972bff4a06a9aa3e848a2a9ef4'
3
+ metadata.gz: 8d8e1d17876b5bb6bc851b841e43c5abca5d3dc6d6b491acedfc571f0022de4a
4
+ data.tar.gz: 01f631fdae83149cf643d2bffb28ff5abf21dae55fea7afec0c40b06704eea61
5
5
  SHA512:
6
- metadata.gz: 3bf39418c02c38ead4fc8ead897064730711d97bcd2e787d049ae1860e89bcf07e155f4ebb3743263e4c531250935d73e02ae768196aa5d27e205f8c3f903d1e
7
- data.tar.gz: 145951b47b0ef2ce55e4c02932c006efdaa48fd6ac556daf5a18fef5949d63ebf861eabf262a93bb1a2a51dcc46b89d9d510fcbcc5c38fc7153dc1a0cd24402b
6
+ metadata.gz: 9479e55672546d80256ace5e39fb9bf3639d2fec1cd554e1977a0a627990036cced67fbfae8e3467388ac7b9140d82c74aac21a684c98da9e3e45e4aa688eb55
7
+ data.tar.gz: 93fd13d1409125462b7931db7cc2b249916c7a05465c656492b89dac0ef137824dcd4a682e3a6df91657d34f2e0f22502faef0e13c9b79b3ddedd171cec4f345
data/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.1] - 2026-05-19
4
+
5
+ ### Fixed
6
+
7
+ - The gem now picks up a pre-installed `lightpanda` from `PATH` (e.g. `brew install lightpanda-io/lightpanda/lightpanda` or a manual `curl` install) instead of always re-downloading the nightly binary into `~/.cache/lightpanda/`. Test suites that block outbound HTTP via VCR/WebMock no longer crash on the surprise GET to `github.com` the first time the driver starts. Auto-download still kicks in if no `lightpanda` is on `PATH` and the gem cache is empty/stale, so default setups are unaffected.
8
+
9
+ ## [0.4.0] - 2026-05-17
10
+
11
+ > **Update Lightpanda before upgrading.** Requires a nightly build ≥ 6269 (published 2026-05-16 or later). The driver refuses to start against older binaries.
12
+
13
+ ### Added
14
+
15
+ - `Driver#status_code` and `Driver#response_headers` — read the HTTP status code and response headers from the most recent document navigation. Lookups on `response_headers` are case-insensitive (`response_headers["Content-Type"]` works against any header casing). Caveat: calling `driver.network.disable` also disables the navigation-response handler — they share a CDP toggle.
16
+ - `Driver#with_lightpanda_browser { |browser| … }` and `element.with_lightpanda_node { |node| … }` — escape hatches for tests that need raw access to Lightpanda's CDP client and `LP.*` extensions. Mixed-driver setups are safe: the element helper no-ops against non-Lightpanda elements.
17
+ - Shifted-symbol keyboard input — `send_keys([:shift, "1"])` now produces `"!"`, `[:shift, "2"]` produces `"@"`, etc., matching a US keyboard layout. Letters fall through to their existing upcase path unchanged.
18
+ - `LIGHTPANDA_EXTRA_ARGS` env var — whitespace-split tokens get appended to the spawned `lightpanda serve` command line, so you can experiment with opt-in upstream flags (e.g. `LIGHTPANDA_EXTRA_ARGS="--log_format pretty"`) without forking the gem.
19
+ - `Binary.configure { |b| b.required_version = "…"; b.cache_time = …; b.install_dir = "…"; b.proxy_addr = … }`, plus `Binary.update`, `Binary.remove`, `Binary.current_version`, `Binary.install_path` — explicit, scriptable binary management. Pair with the new `rake lightpanda:binary:{version, update[version], remove}` tasks if you want to download the binary once before parallel workers start.
20
+
21
+ ### Changed
22
+
23
+ - Inline `<style> @media` rules and `window.matchMedia(q).matches` now evaluate against the fixed 1920×1080 viewport, so visibility checks on mobile-vs-desktop CTAs gated by **inline** `@media` queries no longer surface both variants. External `<link rel="stylesheet">` responsive CSS is still out of scope (browser limitation — see README).
24
+ - `network.headers = { … }`, `network.add_headers(…)`, and `network.clear_headers` now enable the Network domain on first call. No need to flip `network.enable` separately.
25
+
26
+ ### Fixed
27
+
28
+ - A WebSocket-level error (bad frame, oversized message) no longer tears down the Ruby process. Pending CDP commands now fail immediately on disconnect instead of blocking for the full 30 s timeout, so teardown after a browser crash no longer freezes the suite.
29
+ - `Cookies#load` now restores the `SameSite` attribute. Auth-cookie flows that rely on `SameSite=Strict` / `Lax` survive a YAML round-trip through `cookies.store` / `cookies.load`.
30
+ - Stale (detached) element references now raise `ObsoleteNode` from every node operation, not just `text` / `all_text` / `visible_text`. Capybara's automatic-reload re-runs the original query instead of silently reading stale data.
31
+ - A browser crash followed by reconnect now wipes session state (modal subscriptions, frame stack). Subsequent tests no longer see "no dialog fired" or stale iframe handles.
32
+ - `save_screenshot` from Rails' `before_teardown` after a failed system test no longer masks the original failure with a "browser already gone" stack trace.
33
+ - `find_modal` of the wrong type now reports the message of whatever other dialog actually fired — so an `alert` firing where the test expected a `confirm` shows up clearly in the failure message.
34
+ - A `find(...)` immediately after a click that triggers navigation no longer raises `NoExecutionContextError`.
35
+ - A broken or permission-denied `lsof` now surfaces as a `BinaryError` pointing at the underlying problem instead of silently failing port reclamation.
36
+ - `session.scroll_to(node)` no longer raises `NotImplementedError`. Lightpanda still has no rendering so the call does nothing, but callers who didn't care about the result no longer crash.
37
+ - `network.traffic` reads (used by `wait_for_network_idle`) are now thread-safe — no more inconsistent counts when the CDP message thread is busy.
38
+ - Several internal browser-side handle leaks plugged — long shared-spec sessions no longer accumulate orphaned object references.
39
+
40
+ ### Removed
41
+
42
+ - The last JavaScript polyfill bundle (form-IDL accessors like `form.enctype` and submitter `formAction` / `formMethod`) is gone — Lightpanda implements these natively now. No code change required on your end.
43
+ - The iframe-context retry workaround and the `HTMLDialogElement.show` / `showModal` / `close` polyfill — both implemented natively upstream. No code change required.
44
+
45
+ ### Internal
46
+
47
+ - `rake spec:shared:parallel` — the Capybara shared-spec battery now runs in parallel across N worker processes (~3.7× at 4 workers, ~6.6× at 8). Default worker count is `Etc.nprocessors / 2`; override with `RSPEC_WORKERS=N`.
48
+
3
49
  ## [0.3.0] - 2026-05-12
4
50
 
5
51
  > **Update Lightpanda before upgrading.** Requires a nightly build ≥ 6109 (published 2026-05-12). The driver refuses to start against older binaries.
data/README.md CHANGED
@@ -40,6 +40,9 @@ Capybara.javascript_driver = :lightpanda
40
40
  > [!TIP]
41
41
  > The Lightpanda binary is auto-downloaded on first use — no separate install step needed.
42
42
 
43
+ > [!IMPORTANT]
44
+ > Lightpanda is a headless agentic browser, not a layout engine. By design it does **not** fetch external `<link rel="stylesheet">`, evaluate `@media` rules, or implement `window.matchMedia()`. Any spec whose visibility depends on responsive CSS — for example a mobile/desktop CTA pair hidden via `@media (min-width: …)` — should stay on Cuprite (or whichever full-browser driver you were already using). The [dual-driver setup](https://navidemad.github.io/capybara-lightpanda/#docs) routes the layout-sensitive minority to Cuprite and the structural majority to Lightpanda for speed.
45
+
43
46
  ## Credits
44
47
 
45
48
  - [Lightpanda](https://lightpanda.io/) — the headless browser
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Capybara
4
+ module Lightpanda
5
+ module AutoScripts
6
+ JS_PATH = File.expand_path("javascripts/index.js", __dir__).freeze
7
+ JS = File.read(JS_PATH).freeze
8
+ end
9
+ end
10
+ end
@@ -23,7 +23,7 @@ module Capybara
23
23
  end
24
24
  end
25
25
 
26
- GITHUB_RELEASE_URL = "https://github.com/lightpanda-io/browser/releases/download/nightly"
26
+ GITHUB_RELEASE_URL = "https://github.com/lightpanda-io/browser/releases/download"
27
27
 
28
28
  PLATFORMS = {
29
29
  %w[x86_64 linux] => "lightpanda-x86_64-linux",
@@ -31,23 +31,101 @@ module Capybara
31
31
  %w[arm64 darwin] => "lightpanda-aarch64-macos",
32
32
  }.freeze
33
33
 
34
+ DEFAULT_CACHE_TIME = 86_400
35
+
34
36
  class << self
37
+ # Set a specific release tag (e.g. "0.3.0") to pin downloads to that
38
+ # release. When nil, the rolling "nightly" tag is used. The pin only
39
+ # affects download URL construction — the gem's MINIMUM_NIGHTLY_BUILD
40
+ # floor is still enforced at process start.
41
+ attr_accessor :required_version
42
+
43
+ # Seconds before a cached unpinned binary is re-downloaded. Pinned
44
+ # binaries are never refreshed on age (they're pinned).
45
+ attr_writer :cache_time, :install_dir, :logger
46
+ attr_accessor :proxy_addr, :proxy_port, :proxy_user, :proxy_pass
47
+
48
+ def cache_time
49
+ @cache_time ||= Integer(ENV.fetch("LIGHTPANDA_CACHE_TIME", DEFAULT_CACHE_TIME))
50
+ end
51
+
52
+ def install_dir
53
+ @install_dir ||= File.dirname(default_binary_path)
54
+ end
55
+
56
+ def logger
57
+ return @logger if defined?(@logger) && @logger
58
+ return nil unless ENV["LIGHTPANDA_DEBUG"]
59
+
60
+ @logger = Capybara::Lightpanda::Logger.new($stderr.tap { |s| s.sync = true })
61
+ end
62
+
63
+ def configure
64
+ yield self
65
+ end
66
+
35
67
  def path
36
- @path ||= find_or_download
68
+ @path ||= update
37
69
  end
38
70
 
39
- def find_or_download
40
- find || download
71
+ # Canonical entrypoint: ensure the binary at install_path is current,
72
+ # download if needed, return its path. Pinned (required_version set)
73
+ # never re-downloads when present. Unpinned re-downloads when older
74
+ # than cache_time. When unpinned and the gem cache is empty/stale,
75
+ # an already-installed `lightpanda` on PATH (e.g. via Homebrew) wins
76
+ # over re-downloading — keeps test suites running under VCR/WebMock
77
+ # from triggering surprise HTTP to github.com.
78
+ def update
79
+ destination = install_path
80
+
81
+ if required_version
82
+ if File.executable?(destination)
83
+ log("Pinned #{required_version} present at #{destination}")
84
+ return destination
85
+ end
86
+ return download
87
+ end
88
+
89
+ if cached_fresh?(destination)
90
+ log("Cached binary at #{destination} is fresh (< #{cache_time}s)")
91
+ return destination
92
+ end
93
+
94
+ if (system_path = system_binary_path)
95
+ log("Using lightpanda from PATH at #{system_path}")
96
+ return system_path
97
+ end
98
+
99
+ download
41
100
  end
42
101
 
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
102
+ # Delete the cached binary. Returns the path that was deleted, or nil
103
+ # if nothing was there.
104
+ def remove
105
+ path = install_path
106
+ unless File.exist?(path)
107
+ log("Nothing to remove at #{path}")
108
+ return nil
109
+ end
110
+
111
+ File.delete(path)
112
+ @path = nil
113
+ log("Removed #{path}")
48
114
  path
49
115
  end
50
116
 
117
+ # Returns the `lightpanda version` output of the cached binary, or nil
118
+ # if the binary isn't present / not runnable.
119
+ def current_version
120
+ path = install_path
121
+ return nil unless File.executable?(path)
122
+
123
+ stdout, _, status = Open3.capture3(path, "version")
124
+ status.success? ? stdout.strip : nil
125
+ rescue Errno::ENOENT
126
+ nil
127
+ end
128
+
51
129
  def run(*)
52
130
  stdout, stderr, status = Open3.capture3(path, *)
53
131
 
@@ -72,28 +150,18 @@ module Capybara
72
150
  result.output.strip
73
151
  end
74
152
 
75
- def find
76
- env_path = ENV.fetch("LIGHTPANDA_BIN", 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
153
  def download
89
154
  binary_name = platform_binary
90
- url = "#{GITHUB_RELEASE_URL}/#{binary_name}"
91
- destination = default_binary_path
155
+ tag = required_version || "nightly"
156
+ url = "#{GITHUB_RELEASE_URL}/#{tag}/#{binary_name}"
157
+ destination = install_path
92
158
 
159
+ log("Downloading #{binary_name} (#{tag}) → #{destination}")
93
160
  FileUtils.mkdir_p(File.dirname(destination))
94
161
 
95
162
  download_file(url, destination)
96
163
  FileUtils.chmod(0o755, destination)
164
+ @path = destination
97
165
 
98
166
  destination
99
167
  end
@@ -111,35 +179,37 @@ module Capybara
111
179
  File.join(cache_dir, "lightpanda", "lightpanda")
112
180
  end
113
181
 
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)
182
+ # Path the gem writes the downloaded binary to. Honors a
183
+ # user-configured install_dir; otherwise falls back to default_binary_path.
184
+ def install_path
185
+ if @install_dir
186
+ File.join(@install_dir, "lightpanda")
187
+ else
188
+ default_binary_path
121
189
  end
122
-
123
- nil
124
190
  end
125
191
 
126
- def native_binary?(path)
127
- header = File.binread(path, 4)
192
+ private
128
193
 
129
- return true if elf_binary?(header)
130
- return true if mach_o_binary?(header)
194
+ # Scan ENV["PATH"] for an executable `lightpanda`. Returns the first
195
+ # match (PATH order), or nil. Lets `brew install lightpanda-io/
196
+ # lightpanda/lightpanda` (and Linux package installs at /usr/local/bin)
197
+ # short-circuit the auto-download path in `update`.
198
+ def system_binary_path
199
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
200
+ next if dir.empty?
131
201
 
132
- false
133
- rescue StandardError
134
- false
202
+ candidate = File.join(dir, "lightpanda")
203
+ return candidate if File.file?(candidate) && File.executable?(candidate)
204
+ end
205
+ nil
135
206
  end
136
207
 
137
- def elf_binary?(header)
138
- header.start_with?("\x7FELF")
139
- end
208
+ def cached_fresh?(path)
209
+ return false unless File.executable?(path)
210
+ return true if cache_time.zero?
140
211
 
141
- def mach_o_binary?(header)
142
- header.start_with?("\xCF\xFA\xED\xFE")
212
+ (Time.now - File.mtime(path)) < cache_time
143
213
  end
144
214
 
145
215
  def normalize_arch(arch)
@@ -167,7 +237,7 @@ module Capybara
167
237
  def follow_redirects(uri, destination, limit = 10)
168
238
  raise BinaryNotFoundError, "Too many redirects" if limit.zero?
169
239
 
170
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
240
+ http_start(uri) do |http|
171
241
  request = Net::HTTP::Get.new(uri)
172
242
 
173
243
  http.request(request) do |response|
@@ -177,6 +247,7 @@ module Capybara
177
247
  response.read_body { |chunk| file.write(chunk) }
178
248
  end
179
249
  when Net::HTTPRedirection
250
+ log("Redirected → #{response['location']}")
180
251
  follow_redirects(URI.parse(response["location"]), destination, limit - 1)
181
252
  else
182
253
  raise BinaryNotFoundError, "Failed to download binary: #{response.code} #{response.message}"
@@ -184,6 +255,22 @@ module Capybara
184
255
  end
185
256
  end
186
257
  end
258
+
259
+ def http_start(uri, &)
260
+ if proxy_addr
261
+ Net::HTTP.start(
262
+ uri.host, uri.port,
263
+ proxy_addr, proxy_port, proxy_user, proxy_pass,
264
+ use_ssl: uri.scheme == "https", &
265
+ )
266
+ else
267
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", &)
268
+ end
269
+ end
270
+
271
+ def log(message)
272
+ logger&.puts("[lightpanda binary] #{message}")
273
+ end
187
274
  end
188
275
  end
189
276
  end