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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d8e1d17876b5bb6bc851b841e43c5abca5d3dc6d6b491acedfc571f0022de4a
4
- data.tar.gz: 01f631fdae83149cf643d2bffb28ff5abf21dae55fea7afec0c40b06704eea61
3
+ metadata.gz: 9d2922897678cf6ea18bd630b63703ae59ca213b4909e5c42d60e5c1b55e7faa
4
+ data.tar.gz: 8b01db5b4f0b7c8da5e13d2c9b157c2184463e3b22637ec1d6d5d4d3d84d17f7
5
5
  SHA512:
6
- metadata.gz: 9479e55672546d80256ace5e39fb9bf3639d2fec1cd554e1977a0a627990036cced67fbfae8e3467388ac7b9140d82c74aac21a684c98da9e3e45e4aa688eb55
7
- data.tar.gz: 93fd13d1409125462b7931db7cc2b249916c7a05465c656492b89dac0ef137824dcd4a682e3a6df91657d34f2e0f22502faef0e13c9b79b3ddedd171cec4f345
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. External <link rel="stylesheet"> fetch stays
53
- # out of scope by design see .claude/rules/lightpanda-io.md
54
- # limitation #6).
55
- # Build 6269 = first nightly carrying PR #2478 (merge commit
56
- # ab63cfbf, 2026-05-16); the 2026-05-16 nightly cut at 03:36 UTC
57
- # was hours before the merge at 13:42 UTC.
58
- MINIMUM_NIGHTLY_BUILD = Gem::Version.new("6269")
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
- begin
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: curl -sL https://github.com/lightpanda-io/browser/releases/download/nightly/" \
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 method so the finalizer proc doesn't capture `self` (which
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 do
317
- ::Process.kill("TERM", -pid)
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::ESRCH, Errno::ECHILD, Errno::EPERM
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Lightpanda
5
- VERSION = "0.4.1"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: capybara-lightpanda
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Navid Emad