capybara-lightpanda 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9bb84252bec4e22492ad1e2165a63842c3fad60ab9d80ee0df60db55c2425c9a
4
- data.tar.gz: '090fbdca94bf7e482dda301a2aad4b51065211972bff4a06a9aa3e848a2a9ef4'
3
+ metadata.gz: '0295a232795b3fee642523097324529b501ca520427691642949f59815794bb9'
4
+ data.tar.gz: 8469cceee8e75e2d1f12f6be8528c2d80081aabb096aa0deb84bae9a2467d7b3
5
5
  SHA512:
6
- metadata.gz: 3bf39418c02c38ead4fc8ead897064730711d97bcd2e787d049ae1860e89bcf07e155f4ebb3743263e4c531250935d73e02ae768196aa5d27e205f8c3f903d1e
7
- data.tar.gz: 145951b47b0ef2ce55e4c02932c006efdaa48fd6ac556daf5a18fef5949d63ebf861eabf262a93bb1a2a51dcc46b89d9d510fcbcc5c38fc7153dc1a0cd24402b
6
+ metadata.gz: f233ff2cb0f5d1f6c146c1a417360ee7f2ef630f0d82bade3afd13176ee940629254cca842ea04762d174f00a595c7950a40ceb88cc4026dc3708b90675e3750
7
+ data.tar.gz: '0748a7f1786f2eded7256a9d4106ead5da15abf5be28966e4c5525f600b6a9000f11bc878b2697c1f85e315d524cc5569938c38bf2c12c6aa53bb24f7ed3c2b8'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] - 2026-05-17
4
+
5
+ > **Update Lightpanda before upgrading.** Requires a nightly build ≥ 6269 (published 2026-05-16 or later). The driver refuses to start against older binaries.
6
+
7
+ ### Added
8
+
9
+ - `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.
10
+ - `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.
11
+ - 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.
12
+ - `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.
13
+ - `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.
14
+
15
+ ### Changed
16
+
17
+ - 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).
18
+ - `network.headers = { … }`, `network.add_headers(…)`, and `network.clear_headers` now enable the Network domain on first call. No need to flip `network.enable` separately.
19
+
20
+ ### Fixed
21
+
22
+ - 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.
23
+ - `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`.
24
+ - 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.
25
+ - 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.
26
+ - `save_screenshot` from Rails' `before_teardown` after a failed system test no longer masks the original failure with a "browser already gone" stack trace.
27
+ - `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.
28
+ - A `find(...)` immediately after a click that triggers navigation no longer raises `NoExecutionContextError`.
29
+ - A broken or permission-denied `lsof` now surfaces as a `BinaryError` pointing at the underlying problem instead of silently failing port reclamation.
30
+ - `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.
31
+ - `network.traffic` reads (used by `wait_for_network_idle`) are now thread-safe — no more inconsistent counts when the CDP message thread is busy.
32
+ - Several internal browser-side handle leaks plugged — long shared-spec sessions no longer accumulate orphaned object references.
33
+
34
+ ### Removed
35
+
36
+ - 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.
37
+ - The iframe-context retry workaround and the `HTMLDialogElement.show` / `showModal` / `close` polyfill — both implemented natively upstream. No code change required.
38
+
39
+ ### Internal
40
+
41
+ - `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`.
42
+
3
43
  ## [0.3.0] - 2026-05-12
4
44
 
5
45
  > **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,90 @@ 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.
75
+ def update
76
+ destination = install_path
77
+
78
+ if required_version
79
+ if File.executable?(destination)
80
+ log("Pinned #{required_version} present at #{destination}")
81
+ return destination
82
+ end
83
+ elsif cached_fresh?(destination)
84
+ log("Cached binary at #{destination} is fresh (< #{cache_time}s)")
85
+ return destination
86
+ end
87
+
88
+ download
41
89
  end
42
90
 
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
91
+ # Delete the cached binary. Returns the path that was deleted, or nil
92
+ # if nothing was there.
93
+ def remove
94
+ path = install_path
95
+ unless File.exist?(path)
96
+ log("Nothing to remove at #{path}")
97
+ return nil
98
+ end
99
+
100
+ File.delete(path)
101
+ @path = nil
102
+ log("Removed #{path}")
48
103
  path
49
104
  end
50
105
 
106
+ # Returns the `lightpanda version` output of the cached binary, or nil
107
+ # if the binary isn't present / not runnable.
108
+ def current_version
109
+ path = install_path
110
+ return nil unless File.executable?(path)
111
+
112
+ stdout, _, status = Open3.capture3(path, "version")
113
+ status.success? ? stdout.strip : nil
114
+ rescue Errno::ENOENT
115
+ nil
116
+ end
117
+
51
118
  def run(*)
52
119
  stdout, stderr, status = Open3.capture3(path, *)
53
120
 
@@ -72,28 +139,18 @@ module Capybara
72
139
  result.output.strip
73
140
  end
74
141
 
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
142
  def download
89
143
  binary_name = platform_binary
90
- url = "#{GITHUB_RELEASE_URL}/#{binary_name}"
91
- destination = default_binary_path
144
+ tag = required_version || "nightly"
145
+ url = "#{GITHUB_RELEASE_URL}/#{tag}/#{binary_name}"
146
+ destination = install_path
92
147
 
148
+ log("Downloading #{binary_name} (#{tag}) → #{destination}")
93
149
  FileUtils.mkdir_p(File.dirname(destination))
94
150
 
95
151
  download_file(url, destination)
96
152
  FileUtils.chmod(0o755, destination)
153
+ @path = destination
97
154
 
98
155
  destination
99
156
  end
@@ -111,35 +168,23 @@ module Capybara
111
168
  File.join(cache_dir, "lightpanda", "lightpanda")
112
169
  end
113
170
 
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)
171
+ # Path the gem writes the downloaded binary to. Honors a
172
+ # user-configured install_dir; otherwise falls back to default_binary_path.
173
+ def install_path
174
+ if @install_dir
175
+ File.join(@install_dir, "lightpanda")
176
+ else
177
+ default_binary_path
121
178
  end
122
-
123
- nil
124
179
  end
125
180
 
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
181
+ private
136
182
 
137
- def elf_binary?(header)
138
- header.start_with?("\x7FELF")
139
- end
183
+ def cached_fresh?(path)
184
+ return false unless File.executable?(path)
185
+ return true if cache_time.zero?
140
186
 
141
- def mach_o_binary?(header)
142
- header.start_with?("\xCF\xFA\xED\xFE")
187
+ (Time.now - File.mtime(path)) < cache_time
143
188
  end
144
189
 
145
190
  def normalize_arch(arch)
@@ -167,7 +212,7 @@ module Capybara
167
212
  def follow_redirects(uri, destination, limit = 10)
168
213
  raise BinaryNotFoundError, "Too many redirects" if limit.zero?
169
214
 
170
- Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
215
+ http_start(uri) do |http|
171
216
  request = Net::HTTP::Get.new(uri)
172
217
 
173
218
  http.request(request) do |response|
@@ -177,6 +222,7 @@ module Capybara
177
222
  response.read_body { |chunk| file.write(chunk) }
178
223
  end
179
224
  when Net::HTTPRedirection
225
+ log("Redirected → #{response['location']}")
180
226
  follow_redirects(URI.parse(response["location"]), destination, limit - 1)
181
227
  else
182
228
  raise BinaryNotFoundError, "Failed to download binary: #{response.code} #{response.message}"
@@ -184,6 +230,22 @@ module Capybara
184
230
  end
185
231
  end
186
232
  end
233
+
234
+ def http_start(uri, &)
235
+ if proxy_addr
236
+ Net::HTTP.start(
237
+ uri.host, uri.port,
238
+ proxy_addr, proxy_port, proxy_user, proxy_pass,
239
+ use_ssl: uri.scheme == "https", &
240
+ )
241
+ else
242
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", &)
243
+ end
244
+ end
245
+
246
+ def log(message)
247
+ logger&.puts("[lightpanda binary] #{message}")
248
+ end
187
249
  end
188
250
  end
189
251
  end