capybara-lightpanda 0.4.1 → 0.5.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 +14 -0
- data/lib/capybara/lightpanda/binary.rb +37 -0
- data/lib/capybara/lightpanda/browser.rb +45 -0
- data/lib/capybara/lightpanda/process.rb +81 -31
- data/lib/capybara/lightpanda/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9d2922897678cf6ea18bd630b63703ae59ca213b4909e5c42d60e5c1b55e7faa
|
|
4
|
+
data.tar.gz: 8b01db5b4f0b7c8da5e13d2c9b157c2184463e3b22637ec1d6d5d4d3d84d17f7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7e09df8530d946074c1b9022820ebe3ff6c3bc19d69ee1f543df7c3e65c6d6cc2ace6c65910028fb69ca7253488a88df10f3c271bcced82358e71e44c128cb1e
|
|
7
|
+
data.tar.gz: 93feae0c267585dfc745d01ec65d651b682903794588ea2cc18c3c071b313c4b869bde6c35cd8bfbe8c2e00b1c51ac49ba1cd6ddc5b624c33f1f0e7dc8575286
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.5.0] - 2026-05-22
|
|
4
|
+
|
|
5
|
+
> **Update Lightpanda before upgrading.** Requires a nightly build ≥ 6353 (published 2026-05-21). The driver refuses to start against older binaries.
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- External `<link rel="stylesheet">` CSS is now fetched and applied by default. Responsive variants gated by an external stylesheet — a desktop-vs-mobile CTA defined in your app's compiled CSS bundle, say — now resolve to the single variant matching the fixed 1920×1080 viewport, so finding or clicking them no longer raises `Capybara::Ambiguous`. Specs that previously had to fall back to cuprite or Selenium for externally-loaded responsive CSS now run on lightpanda. The cost is one extra synchronous CSS fetch per `<link>` while the page loads.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Suites no longer hang at exit. After the last test, the browser process could absorb the shutdown signal and keep the run blocked for minutes (much longer on CI) before being force-killed. The gem now closes the CDP connection before signaling the process and escalates to a hard kill if it doesn't exit promptly, so the process always dies fast and leaves no orphaned `lightpanda` behind.
|
|
14
|
+
- Elements toggled via the `[hidden]` attribute now respect an explicit `display` set by your app's CSS, the way a real browser does. This clears the `Capybara::ElementNotFound` and visibility failures that hit dropdowns and disclosure widgets built with Stimulus or Alpine.
|
|
15
|
+
- When your installed Lightpanda binary is too old, the update hint now matches where that binary actually lives — a `PATH` install, the gem's download cache, or a Homebrew install each get the right command instead of a generic one. Especially handy this release, since the raised floor means an outdated binary will refuse to start.
|
|
16
|
+
|
|
3
17
|
## [0.4.1] - 2026-05-19
|
|
4
18
|
|
|
5
19
|
### Fixed
|
|
@@ -166,6 +166,30 @@ module Capybara
|
|
|
166
166
|
destination
|
|
167
167
|
end
|
|
168
168
|
|
|
169
|
+
# Build a path-appropriate "how to update" command for Process's
|
|
170
|
+
# too-old-binary error. Three branches:
|
|
171
|
+
#
|
|
172
|
+
# - Symlink into a `/Cellar/` directory → installed via Homebrew;
|
|
173
|
+
# suggest `brew update && brew upgrade lightpanda` (brew pins
|
|
174
|
+
# each user's binary at install time and doesn't refresh on its
|
|
175
|
+
# own when the tap publishes a newer nightly).
|
|
176
|
+
# - Path equals our own cache → suggest the gem's rake tasks; the
|
|
177
|
+
# `remove` step is required because `update` honors `cache_time`
|
|
178
|
+
# and would otherwise no-op on a too-old-but-not-yet-expired file.
|
|
179
|
+
# - Anything else (user-managed install at a custom path) → keep
|
|
180
|
+
# the curl-overwrite suggestion, since we don't know how the file
|
|
181
|
+
# got there.
|
|
182
|
+
def update_hint(binary_path)
|
|
183
|
+
if brew_managed?(binary_path)
|
|
184
|
+
"brew update && brew upgrade lightpanda"
|
|
185
|
+
elsif binary_path == install_path
|
|
186
|
+
"bundle exec rake lightpanda:binary:remove lightpanda:binary:update"
|
|
187
|
+
else
|
|
188
|
+
"curl -sL #{GITHUB_RELEASE_URL}/nightly/#{platform_binary} " \
|
|
189
|
+
"-o #{binary_path} && chmod +x #{binary_path}"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
169
193
|
def platform_binary
|
|
170
194
|
arch = normalize_arch(RbConfig::CONFIG["host_cpu"])
|
|
171
195
|
os = normalize_os(RbConfig::CONFIG["host_os"])
|
|
@@ -191,6 +215,19 @@ module Capybara
|
|
|
191
215
|
|
|
192
216
|
private
|
|
193
217
|
|
|
218
|
+
# Detects a Homebrew-managed binary by checking whether `path` is a
|
|
219
|
+
# symlink that resolves into a `/Cellar/` directory — the convention
|
|
220
|
+
# both `/opt/homebrew` (Apple Silicon) and `/usr/local` (Intel /
|
|
221
|
+
# linuxbrew) use. Plain files (manual installs) and gem-cache files
|
|
222
|
+
# both return false.
|
|
223
|
+
def brew_managed?(path)
|
|
224
|
+
return false unless File.symlink?(path)
|
|
225
|
+
|
|
226
|
+
File.realpath(path).include?("/Cellar/")
|
|
227
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
228
|
+
false
|
|
229
|
+
end
|
|
230
|
+
|
|
194
231
|
# Scan ENV["PATH"] for an executable `lightpanda`. Returns the first
|
|
195
232
|
# match (PATH order), or nil. Lets `brew install lightpanda-io/
|
|
196
233
|
# lightpanda/lightpanda` (and Linux package installs at /usr/local/bin)
|
|
@@ -11,6 +11,49 @@ module Capybara
|
|
|
11
11
|
|
|
12
12
|
delegate %i[on off] => :client
|
|
13
13
|
|
|
14
|
+
# --- Live-browser registry: clean teardown at process exit --------------
|
|
15
|
+
# Capybara's per-test reset (Driver#reset!) disposes only the
|
|
16
|
+
# BrowserContext and keeps the process + CDP connection alive, so a
|
|
17
|
+
# browser outlives the suite. With nothing calling #quit at exit, teardown
|
|
18
|
+
# would fall to the Process GC finalizer, which SIGTERMs the binary WITHOUT
|
|
19
|
+
# first closing the CDP WebSocket. Lightpanda swallows a *single* SIGTERM
|
|
20
|
+
# while a CDP connection is live (graceful shutdown blocks on the
|
|
21
|
+
# connection worker; it takes three signals to force-exit), so that SIGTERM
|
|
22
|
+
# is absorbed and only the STOP_GRACE_SECONDS SIGKILL escalation reaps it —
|
|
23
|
+
# 3s per process, and a hard hang before that escalation existed. #quit
|
|
24
|
+
# closes the WS first, so we track live browsers and #quit them from a
|
|
25
|
+
# single at_exit, guaranteeing the socket is closed before any SIGTERM.
|
|
26
|
+
@live = []
|
|
27
|
+
@live_mutex = Mutex.new
|
|
28
|
+
@at_exit_installed = false
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
def track(browser)
|
|
32
|
+
@live_mutex.synchronize do
|
|
33
|
+
@live << browser unless @live.include?(browser)
|
|
34
|
+
next if @at_exit_installed
|
|
35
|
+
|
|
36
|
+
@at_exit_installed = true
|
|
37
|
+
at_exit { quit_all }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def untrack(browser)
|
|
42
|
+
@live_mutex.synchronize { @live.delete(browser) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# at_exit handler: close every live browser's CDP WebSocket (via #quit)
|
|
46
|
+
# before its Process finalizer can SIGTERM the binary. Per-browser rescue
|
|
47
|
+
# so one wedged browser can't strand the rest.
|
|
48
|
+
def quit_all
|
|
49
|
+
@live_mutex.synchronize { @live.dup }.each do |browser|
|
|
50
|
+
browser.quit
|
|
51
|
+
rescue StandardError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
14
57
|
# Lightpanda binary version (e.g. "lightpanda 0.2.9 nightly.5267") and
|
|
15
58
|
# parsed nightly build number, captured at Process startup. nil when
|
|
16
59
|
# the gem is connecting to an externally-managed Lightpanda via ws_url.
|
|
@@ -58,6 +101,7 @@ module Capybara
|
|
|
58
101
|
create_page
|
|
59
102
|
|
|
60
103
|
@started = true
|
|
104
|
+
self.class.track(self)
|
|
61
105
|
end
|
|
62
106
|
|
|
63
107
|
# Per-session BrowserContext (Chrome's incognito-profile primitive).
|
|
@@ -144,6 +188,7 @@ module Capybara
|
|
|
144
188
|
end
|
|
145
189
|
|
|
146
190
|
def quit
|
|
191
|
+
self.class.untrack(self)
|
|
147
192
|
begin
|
|
148
193
|
@client&.close
|
|
149
194
|
rescue StandardError
|
|
@@ -8,6 +8,19 @@ module Capybara
|
|
|
8
8
|
READY_PATTERN = /server running.*address\s*=\s*(\d+\.\d+\.\d+\.\d+:\d+)/m
|
|
9
9
|
ADDRESS_IN_USE_PATTERN = /err=AddressInUse/
|
|
10
10
|
|
|
11
|
+
# Seconds to wait for a graceful SIGTERM before escalating to SIGKILL in
|
|
12
|
+
# `stop` / the GC finalizer. Lightpanda absorbs a *single* SIGTERM while a
|
|
13
|
+
# CDP connection is still live (graceful shutdown blocks on the connection
|
|
14
|
+
# worker — see .claude/rules/lightpanda-io.md limitation #7B). The PRIMARY
|
|
15
|
+
# fix is gem-side: Browser closes the CDP WebSocket before SIGTERM at exit
|
|
16
|
+
# (Browser.quit_all via at_exit), so SIGTERM lands after EOF and teardown is
|
|
17
|
+
# instant. This escalation is the BACKSTOP for crash / GC-abandon paths the
|
|
18
|
+
# at_exit can't reach — without it, a SIGTERM left to the finalizer (which
|
|
19
|
+
# can't close the WS) blocked Process.wait forever (the 45-min
|
|
20
|
+
# `rake test:all` hang). NOT the same as #2507/#2509 (telemetry curl-multi),
|
|
21
|
+
# which the gem never hits because it disables telemetry.
|
|
22
|
+
STOP_GRACE_SECONDS = 3
|
|
23
|
+
|
|
11
24
|
# Floor for the cookie/navigation/redirect/modal/keyboard/css/forms/dispatch/
|
|
12
25
|
# xpath/history/iframe-context/dialog fixes the gem now relies on:
|
|
13
26
|
# PR #2255 (Network.clearBrowserCookies empty params + Network.getAllCookies),
|
|
@@ -49,13 +62,23 @@ module Capybara
|
|
|
49
62
|
# hardcoded 1920×1080 viewport, and window.matchMedia(q).matches
|
|
50
63
|
# returns spec-correct booleans. Lets _lightpanda.isVisible detect
|
|
51
64
|
# inline-@media-gated hides via el.checkVisibility() without any
|
|
52
|
-
# gem-side workaround
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
|
|
65
|
+
# gem-side workaround),
|
|
66
|
+
# PR #2487 (css: external <link rel="stylesheet"> fetch behind the
|
|
67
|
+
# --enable-external-stylesheets flag — build_args now passes that flag
|
|
68
|
+
# unconditionally, so the floor MUST include the build that introduced
|
|
69
|
+
# it; the flag is a fatal UnknownOption on builds < 6353),
|
|
70
|
+
# PR #2498 (StyleManager: author display rule beats UA [hidden] — fixes
|
|
71
|
+
# the Stimulus/Alpine dropdown ElementNotFound).
|
|
72
|
+
# NOTE: the gem's teardown hang is the live-CDP-connection SIGTERM hang
|
|
73
|
+
# (limitation #7B) — telemetry-independent, present on 6353 AND on the #2509
|
|
74
|
+
# fix build, handled by the at_exit WS-close plus the SIGKILL backstop
|
|
75
|
+
# above. It is NOT #2507 (telemetry curl-multi, fixed by #2509): the gem
|
|
76
|
+
# disables telemetry, so it never creates the curl multi #2507 needs. Keep
|
|
77
|
+
# both teardown defenses even after #2511 (the variant-B fix, MERGED in
|
|
78
|
+
# build 6371) lands in a nightly.
|
|
79
|
+
# Build 6353 = main HEAD merge f1b0adf9 (2026-05-20) carrying #2487 +
|
|
80
|
+
# #2498; the first published nightly with it is the 2026-05-21 cut.
|
|
81
|
+
MINIMUM_NIGHTLY_BUILD = Gem::Version.new("6353")
|
|
59
82
|
|
|
60
83
|
attr_reader :pid, :ws_url, :version, :nightly_build
|
|
61
84
|
|
|
@@ -89,23 +112,7 @@ module Capybara
|
|
|
89
112
|
def stop
|
|
90
113
|
return unless @pid
|
|
91
114
|
|
|
92
|
-
|
|
93
|
-
::Process.kill("TERM", -@pid) # Kill process group
|
|
94
|
-
rescue Errno::ESRCH, Errno::EPERM
|
|
95
|
-
# Process group already dead, try direct
|
|
96
|
-
begin
|
|
97
|
-
::Process.kill("TERM", @pid)
|
|
98
|
-
rescue Errno::ESRCH
|
|
99
|
-
# Process already dead
|
|
100
|
-
end
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
begin
|
|
104
|
-
::Process.wait(@pid)
|
|
105
|
-
rescue Errno::ECHILD
|
|
106
|
-
# Already reaped
|
|
107
|
-
end
|
|
108
|
-
|
|
115
|
+
self.class.send(:terminate, @pid)
|
|
109
116
|
cleanup_pipes
|
|
110
117
|
@pid = nil
|
|
111
118
|
end
|
|
@@ -135,8 +142,7 @@ module Capybara
|
|
|
135
142
|
raise BinaryError,
|
|
136
143
|
"Lightpanda #{@version} is too old. " \
|
|
137
144
|
"This gem requires build >= #{MINIMUM_NIGHTLY_BUILD}. " \
|
|
138
|
-
"Update:
|
|
139
|
-
"#{Binary.platform_binary} -o #{binary_path} && chmod +x #{binary_path}"
|
|
145
|
+
"Update: #{Binary.update_hint(binary_path)}"
|
|
140
146
|
rescue Errno::ENOENT
|
|
141
147
|
# Binary not runnable — let attempt_start handle it
|
|
142
148
|
end
|
|
@@ -201,6 +207,10 @@ module Capybara
|
|
|
201
207
|
@options.port.to_s,
|
|
202
208
|
"--log_level",
|
|
203
209
|
"info",
|
|
210
|
+
# External stylesheet fetch (PR #2487, build >= 6353 — enforced by the
|
|
211
|
+
# floor). Always on so linked CSS contributes to checkVisibility /
|
|
212
|
+
# getComputedStyle; see .claude/rules/lightpanda-io.md limitation #6.
|
|
213
|
+
"--enable-external-stylesheets",
|
|
204
214
|
]
|
|
205
215
|
extra = ENV.fetch("LIGHTPANDA_EXTRA_ARGS", "").split
|
|
206
216
|
base + extra
|
|
@@ -307,19 +317,59 @@ module Capybara
|
|
|
307
317
|
end
|
|
308
318
|
end
|
|
309
319
|
|
|
310
|
-
# Class
|
|
311
|
-
# would prevent GC from ever running the finalizer).
|
|
320
|
+
# Class methods so the finalizer proc doesn't capture `self` (which
|
|
321
|
+
# would prevent GC from ever running the finalizer). `terminate` is shared
|
|
322
|
+
# by the instance `#stop` and the finalizer so both escalate TERM -> KILL.
|
|
312
323
|
class << self
|
|
313
324
|
private
|
|
314
325
|
|
|
315
326
|
def weak_kill(pid)
|
|
316
|
-
proc
|
|
317
|
-
|
|
327
|
+
proc { terminate(pid) }
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# SIGTERM the process group, then SIGKILL if it hasn't exited within
|
|
331
|
+
# `grace` seconds; reap the child. Safe on an already-dead pid. The
|
|
332
|
+
# SIGKILL escalation is what keeps teardown from hanging on builds that
|
|
333
|
+
# ignore SIGTERM after serving CDP (see STOP_GRACE_SECONDS).
|
|
334
|
+
def terminate(pid, grace: STOP_GRACE_SECONDS)
|
|
335
|
+
signal(pid, "TERM")
|
|
336
|
+
return if reap_within(pid, grace)
|
|
337
|
+
|
|
338
|
+
signal(pid, "KILL")
|
|
339
|
+
begin
|
|
318
340
|
::Process.wait(pid)
|
|
319
|
-
rescue Errno::
|
|
341
|
+
rescue Errno::ECHILD
|
|
342
|
+
nil
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Signal the process group (-pid), falling back to the bare pid if the
|
|
347
|
+
# group send is rejected (ESRCH/EPERM).
|
|
348
|
+
def signal(pid, name)
|
|
349
|
+
::Process.kill(name, -pid)
|
|
350
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
351
|
+
begin
|
|
352
|
+
::Process.kill(name, pid)
|
|
353
|
+
rescue Errno::ESRCH
|
|
320
354
|
nil
|
|
321
355
|
end
|
|
322
356
|
end
|
|
357
|
+
|
|
358
|
+
# True once `pid` is reaped (or already gone); false if still alive
|
|
359
|
+
# after `seconds`.
|
|
360
|
+
def reap_within(pid, seconds)
|
|
361
|
+
deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + seconds
|
|
362
|
+
loop do
|
|
363
|
+
begin
|
|
364
|
+
return true if ::Process.wait(pid, ::Process::WNOHANG)
|
|
365
|
+
rescue Errno::ECHILD
|
|
366
|
+
return true
|
|
367
|
+
end
|
|
368
|
+
return false if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) >= deadline
|
|
369
|
+
|
|
370
|
+
sleep 0.05
|
|
371
|
+
end
|
|
372
|
+
end
|
|
323
373
|
end
|
|
324
374
|
|
|
325
375
|
# `start` may be called more than once on the same Process instance
|