capybara-lightpanda 0.4.1 → 0.6.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: ad9d7f3b8897578e98e75f2b27f35ec4f2178c4ef884d9b69b79ad8d60378a42
4
+ data.tar.gz: ce602d8d708904f4f2f5dced507f838445fccb6693df8350779cf544c6871fa4
5
5
  SHA512:
6
- metadata.gz: 9479e55672546d80256ace5e39fb9bf3639d2fec1cd554e1977a0a627990036cced67fbfae8e3467388ac7b9140d82c74aac21a684c98da9e3e45e4aa688eb55
7
- data.tar.gz: 93fd13d1409125462b7931db7cc2b249916c7a05465c656492b89dac0ef137824dcd4a682e3a6df91657d34f2e0f22502faef0e13c9b79b3ddedd171cec4f345
6
+ metadata.gz: 05d437473e7f35d10af8b8d65e460f9808e395df438881fdb6eac5e0d8af3cfce36cee4fb156e17803889645411508778c927570987d85fb7512190a7af49dfe
7
+ data.tar.gz: edb94ac9d172c62b61496d3a92f9c408406642c9053517f053ba8829a4c704847f9c9d1903a5b79fb0acdb743aa26f476a092e8b3050b9677d2896fae0e637e1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.0] - 2026-06-11
4
+
5
+ > **Update Lightpanda before upgrading.** Requires a nightly build ≥ 6699 (published 2026-06-11). The driver refuses to start against older binaries.
6
+
7
+ ### Added
8
+
9
+ - File upload through Capybara's standard `attach_file` now works end to end. `attach_file("Avatar", "/path/to/photo.png")` populates the `<input type=file>` and fires `change`, and — the piece that was missing before — the file's bytes are submitted with the form as proper multipart data (filename, `Content-Type`, content). So uploads the server actually receives (avatar pickers, CSV/import forms, ActiveStorage attachments) can be driven and asserted, not just the client-side `change` handler. File paths are read on the machine running Lightpanda.
10
+ - Drag-and-drop file and data upload through Capybara's standard `Element#drop`. `find("#dropzone").drop("/path/to/report.pdf")` reads the file and rebuilds it as a `File` inside the page; `find("#dropzone").drop("text/plain" => "hello")` drops typed string data. The driver builds a `DataTransfer` and fires `dragenter` → `dragover` → `drop`, so HTML5 dropzones (React Dropzone, Uppy, ActiveStorage direct-upload) receive the payload through `event.dataTransfer.files` / `.items`. This is drag-and-drop *upload*, not element-to-element `drag_to`, which still needs the layout geometry Lightpanda doesn't have.
11
+
12
+ ## [0.5.0] - 2026-05-22
13
+
14
+ > **Update Lightpanda before upgrading.** Requires a nightly build ≥ 6353 (published 2026-05-21). The driver refuses to start against older binaries.
15
+
16
+ ### Changed
17
+
18
+ - 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.
19
+
20
+ ### Fixed
21
+
22
+ - 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.
23
+ - 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.
24
+ - 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.
25
+
3
26
  ## [0.4.1] - 2026-05-19
4
27
 
5
28
  ### 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).
@@ -92,11 +136,6 @@ module Capybara
92
136
  # would otherwise require explicit cookies.clear / storage.clear /
93
137
  # close-target dance, and the browser auto-isolates state for the
94
138
  # new context. Driver#reset! delegates here.
95
- #
96
- # Side benefit: avoids `Page.navigate("about:blank")` against a
97
- # non-blank tab, which doesn't actually replace the document on
98
- # current Lightpanda nightly (lightpanda-io/browser#2363). The
99
- # context-disposal path sidesteps that bug entirely.
100
139
  def reset
101
140
  dispose_browser_context
102
141
  @client.clear_subscriptions
@@ -144,6 +183,7 @@ module Capybara
144
183
  end
145
184
 
146
185
  def quit
186
+ self.class.untrack(self)
147
187
  begin
148
188
  @client&.close
149
189
  rescue StandardError
@@ -463,6 +503,17 @@ module Capybara
463
503
  page_command("DOM.describeNode", objectId: remote_object_id).dig("node", "backendNodeId")
464
504
  end
465
505
 
506
+ # Populate a file <input> from one or more local file paths via
507
+ # DOM.setFileInputFiles (PR #2635, build ≥6625): Lightpanda resolves the
508
+ # objectId, replaces input.files with a real FileList, and fires
509
+ # `input`/`change`. The submitted form then carries the bytes as
510
+ # multipart/form-data (PR #2654, build ≥6672) — both halves are needed,
511
+ # which is why MINIMUM_NIGHTLY_BUILD sits at the 6672 floor. Paths are
512
+ # read off the machine running Lightpanda (local for the spawned process).
513
+ def set_file_input_files(remote_object_id, paths)
514
+ page_command("DOM.setFileInputFiles", objectId: remote_object_id, files: paths)
515
+ end
516
+
466
517
  def screenshot(path: nil, format: :png, quality: nil, full_page: false, encoding: :binary)
467
518
  params = { format: format.to_s }
468
519
  params[:quality] = quality if quality && format == :jpeg
@@ -113,14 +113,18 @@ module Capybara
113
113
  call("function() { this.dispatchEvent(new MouseEvent('mouseover', {bubbles: true, cancelable: true})) }")
114
114
  end
115
115
 
116
- # Lightpanda has no rendering engine `window.scrollTo` / `scrollIntoView`
117
- # are no-ops at the browser level, and `getBoundingClientRect` reflects
118
- # logical-DOM geometry rather than scroll-aware viewport coords. So
119
- # there's nothing to scroll. Silently succeed so callers like
120
- # `session.scroll_to(find('#thing'))` (Selenium-flavoured specs leaning
121
- # on real layout) don't crash with NotImplementedError; assertions that
122
- # depend on post-scroll visibility are already gated by the cuprite
123
- # fallback in dual-driver setups.
116
+ # Kept as a deliberate no-op despite upstream now tracking scroll position
117
+ # (`window.scrollTo`/`scrollBy` update `window._scroll_pos`, `Element`
118
+ # exposes `scrollTop`/`scrollLeft` Window.zig/Element.zig). Wiring it
119
+ # would still misbehave: Lightpanda never clamps to content height
120
+ # (`scrollHeight`/`clientHeight` are a hardcoded 1e8), so `:bottom`/`:center`
121
+ # are meaningless; element scroll is decoupled from window scroll; and with
122
+ # no layout `getBoundingClientRect` isn't scroll-aware, so `scroll_to(el,
123
+ # align:)` can't position anything. So there's nothing meaningful to scroll
124
+ # to. Silently succeed so callers like `session.scroll_to(find('#thing'))`
125
+ # don't crash with NotImplementedError. The `:scroll` capability stays in
126
+ # `capybara_skip`. (Window-position scroll IS reachable for real via
127
+ # `execute_script('window.scrollTo(...)')` if a caller truly needs it.)
124
128
  def scroll_to(*); end
125
129
  def scroll_by(*); end
126
130
 
@@ -147,6 +151,21 @@ module Capybara
147
151
  end
148
152
  end
149
153
 
154
+ # Capybara's drag-and-drop API (`Element#drop`). String/Pathname arguments
155
+ # are file paths — read here and rebuilt as `File` objects in the page;
156
+ # Hash arguments are `{ mime_type => data }` string drops. We assemble a
157
+ # `DataTransfer` and fire `dragenter` -> `dragover` -> `drop` on this
158
+ # element, so HTML5 dropzones see the payload via `event.dataTransfer`.
159
+ #
160
+ # DataTransfer/DataTransferItem/DragEvent landed upstream in PR #2671
161
+ # (build ≥6699) and are guaranteed by the MINIMUM_NIGHTLY_BUILD floor;
162
+ # without them the drop JS raises "DataTransfer is not defined".
163
+ def drop(*args)
164
+ files, strings = partition_drop_args(args)
165
+ call(DROP_JS, files.to_json, strings.to_json)
166
+ nil
167
+ end
168
+
150
169
  def select_option
151
170
  call(SELECT_OPTION_JS)
152
171
  end
@@ -272,7 +291,13 @@ module Capybara
272
291
  when "checkbox", "radio"
273
292
  call(SET_CHECKBOX_JS, value ? true : false)
274
293
  when "file"
275
- raise NotImplementedError, "File uploads not yet supported by Lightpanda"
294
+ # DOM.setFileInputFiles (PR #2635, build ≥6625) sets input.files +
295
+ # fires change; multipart form submission carries the bytes upstream
296
+ # (PR #2654, build ≥6672, webapi/net/FormData.zig). Both halves are in
297
+ # the floor, so attach_file uploads end-to-end. `value` is a path
298
+ # String or Array<String> (multiple: true); cast each element so a
299
+ # Pathname / non-string locator still serializes over CDP.
300
+ driver.browser.set_file_input_files(@remote_object_id, Array(value).map(&:to_s))
276
301
  when "date"
277
302
  call(SET_VALUE_JS, format_date_value(value))
278
303
  when "time"
@@ -349,6 +374,44 @@ module Capybara
349
374
  n.positive? ? str[0, n] : str
350
375
  end
351
376
 
377
+ # Split `drop` arguments into file descriptors and string-data descriptors.
378
+ # Strings/Pathnames (Capybara normalizes `#to_path`) are file paths, read
379
+ # here and base64-encoded so binary content survives the JSON hop; Hashes
380
+ # are `{ type => data }` string drops. Returns `[files, strings]`.
381
+ def partition_drop_args(args)
382
+ files = []
383
+ strings = []
384
+ args.each do |arg|
385
+ if arg.is_a?(Hash)
386
+ arg.each { |type, data| strings << { type: type.to_s, data: data.to_s } }
387
+ else
388
+ path = arg.to_s
389
+ files << {
390
+ name: File.basename(path),
391
+ type: drop_mime_for(path),
392
+ b64: [File.binread(path)].pack("m0"),
393
+ }
394
+ end
395
+ end
396
+ [files, strings]
397
+ end
398
+
399
+ # The dropzone handler only reads `file.name`, but real upload widgets key
400
+ # off `file.type`, so map the common upload extensions and fall back to a
401
+ # generic binary type.
402
+ DROP_MIME_TYPES = {
403
+ ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg", ".png" => "image/png",
404
+ ".gif" => "image/gif", ".webp" => "image/webp", ".svg" => "image/svg+xml",
405
+ ".pdf" => "application/pdf", ".txt" => "text/plain", ".csv" => "text/csv",
406
+ ".json" => "application/json", ".html" => "text/html", ".xml" => "application/xml",
407
+ ".zip" => "application/zip",
408
+ }.freeze
409
+ private_constant :DROP_MIME_TYPES
410
+
411
+ def drop_mime_for(path)
412
+ DROP_MIME_TYPES.fetch(File.extname(path).downcase, "application/octet-stream")
413
+ end
414
+
352
415
  # Whitespace-normalized text (Cuprite pattern). Capybara's text matchers compare
353
416
  # against this, and Lightpanda's textContent preserves source-template whitespace
354
417
  # differently than Chrome — without normalization, multi-line fixtures fail
@@ -474,6 +537,26 @@ module Capybara
474
537
  }
475
538
  JS
476
539
 
540
+ # Build a DataTransfer from the JSON payloads and replay the HTML5 drop
541
+ # sequence on this element. Files arrive base64-encoded and are rebuilt
542
+ # with `atob` (Blob accepts the binary string directly); string drops are
543
+ # added as typed items so the page can read them via getData/getAsString.
544
+ DROP_JS = <<~JS
545
+ function(filesJson, stringsJson) {
546
+ var el = this;
547
+ var dt = new DataTransfer();
548
+ JSON.parse(filesJson).forEach(function(f) {
549
+ dt.items.add(new File([atob(f.b64)], f.name, { type: f.type }));
550
+ });
551
+ JSON.parse(stringsJson).forEach(function(s) {
552
+ dt.items.add(s.data, s.type);
553
+ });
554
+ ['dragenter', 'dragover', 'drop'].forEach(function(name) {
555
+ el.dispatchEvent(new DragEvent(name, { bubbles: true, cancelable: true, dataTransfer: dt }));
556
+ });
557
+ }
558
+ JS
559
+
477
560
  VISIBLE_JS = "function() { return _lightpanda.isVisible(this); }"
478
561
 
479
562
  VISIBLE_TEXT_JS = "function() { return _lightpanda.visibleText(this); }"
@@ -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,38 @@ 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
+ # PR #2635 (dom: DOM.setFileInputFiles backs input.files + fires change
73
+ # for <input type=file>) AND PR #2654 (forms: encode file inputs as
74
+ # multipart/form-data on submit — filename + Content-Type + bytes per
75
+ # RFC 7578). Both halves are required for attach_file to upload end-to-end:
76
+ # #2635 populates the FileList, #2654 makes form submission carry the
77
+ # bytes. Node#fill_input's `when "file"` branch calls
78
+ # Browser#set_file_input_files, so the floor MUST include the #2654 build;
79
+ # on builds 6625–6671 the file attaches but the form submits empty.
80
+ # NOTE: the gem's teardown hang is the live-CDP-connection SIGTERM hang
81
+ # (limitation #7B) — telemetry-independent, present on 6353 AND on the #2509
82
+ # fix build, handled by the at_exit WS-close plus the SIGKILL backstop
83
+ # above. It is NOT #2507 (telemetry curl-multi, fixed by #2509): the gem
84
+ # disables telemetry, so it never creates the curl multi #2507 needs. Keep
85
+ # both teardown defenses even after #2511 (the variant-B fix, MERGED in
86
+ # build 6371) lands in a nightly.
87
+ # Build 6672 = the #2654 merge (22d1c5ec, 2026-06-08) — the first commit
88
+ # carrying both file-upload halves.
89
+ # PR #2671 (DataTransfer / DataTransferItem / DataTransferItemList +
90
+ # DragEvent, merged 2026-06-10) provides the APIs Node#drop's DROP_JS
91
+ # assembles its payload from; on builds without it the drop JS raises
92
+ # "DataTransfer is not defined".
93
+ # Build 6699 = the #2671 merge (d1f4c409, 2026-06-10) — now the binding
94
+ # floor. (The prior 6672 file-upload floor — and 6353 before it — are
95
+ # subsumed.)
96
+ MINIMUM_NIGHTLY_BUILD = Gem::Version.new("6699")
59
97
 
60
98
  attr_reader :pid, :ws_url, :version, :nightly_build
61
99
 
@@ -89,23 +127,7 @@ module Capybara
89
127
  def stop
90
128
  return unless @pid
91
129
 
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
-
130
+ self.class.send(:terminate, @pid)
109
131
  cleanup_pipes
110
132
  @pid = nil
111
133
  end
@@ -135,8 +157,7 @@ module Capybara
135
157
  raise BinaryError,
136
158
  "Lightpanda #{@version} is too old. " \
137
159
  "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}"
160
+ "Update: #{Binary.update_hint(binary_path)}"
140
161
  rescue Errno::ENOENT
141
162
  # Binary not runnable — let attempt_start handle it
142
163
  end
@@ -201,6 +222,10 @@ module Capybara
201
222
  @options.port.to_s,
202
223
  "--log_level",
203
224
  "info",
225
+ # External stylesheet fetch (PR #2487, build >= 6353 — enforced by the
226
+ # floor). Always on so linked CSS contributes to checkVisibility /
227
+ # getComputedStyle; see .claude/rules/lightpanda-io.md limitation #6.
228
+ "--enable-external-stylesheets",
204
229
  ]
205
230
  extra = ENV.fetch("LIGHTPANDA_EXTRA_ARGS", "").split
206
231
  base + extra
@@ -307,19 +332,59 @@ module Capybara
307
332
  end
308
333
  end
309
334
 
310
- # Class method so the finalizer proc doesn't capture `self` (which
311
- # would prevent GC from ever running the finalizer).
335
+ # Class methods so the finalizer proc doesn't capture `self` (which
336
+ # would prevent GC from ever running the finalizer). `terminate` is shared
337
+ # by the instance `#stop` and the finalizer so both escalate TERM -> KILL.
312
338
  class << self
313
339
  private
314
340
 
315
341
  def weak_kill(pid)
316
- proc do
317
- ::Process.kill("TERM", -pid)
342
+ proc { terminate(pid) }
343
+ end
344
+
345
+ # SIGTERM the process group, then SIGKILL if it hasn't exited within
346
+ # `grace` seconds; reap the child. Safe on an already-dead pid. The
347
+ # SIGKILL escalation is what keeps teardown from hanging on builds that
348
+ # ignore SIGTERM after serving CDP (see STOP_GRACE_SECONDS).
349
+ def terminate(pid, grace: STOP_GRACE_SECONDS)
350
+ signal(pid, "TERM")
351
+ return if reap_within(pid, grace)
352
+
353
+ signal(pid, "KILL")
354
+ begin
318
355
  ::Process.wait(pid)
319
- rescue Errno::ESRCH, Errno::ECHILD, Errno::EPERM
356
+ rescue Errno::ECHILD
357
+ nil
358
+ end
359
+ end
360
+
361
+ # Signal the process group (-pid), falling back to the bare pid if the
362
+ # group send is rejected (ESRCH/EPERM).
363
+ def signal(pid, name)
364
+ ::Process.kill(name, -pid)
365
+ rescue Errno::ESRCH, Errno::EPERM
366
+ begin
367
+ ::Process.kill(name, pid)
368
+ rescue Errno::ESRCH
320
369
  nil
321
370
  end
322
371
  end
372
+
373
+ # True once `pid` is reaped (or already gone); false if still alive
374
+ # after `seconds`.
375
+ def reap_within(pid, seconds)
376
+ deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + seconds
377
+ loop do
378
+ begin
379
+ return true if ::Process.wait(pid, ::Process::WNOHANG)
380
+ rescue Errno::ECHILD
381
+ return true
382
+ end
383
+ return false if ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) >= deadline
384
+
385
+ sleep 0.05
386
+ end
387
+ end
323
388
  end
324
389
 
325
390
  # `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.6.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.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Navid Emad