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 +4 -4
- data/CHANGELOG.md +23 -0
- data/lib/capybara/lightpanda/binary.rb +37 -0
- data/lib/capybara/lightpanda/browser.rb +56 -5
- data/lib/capybara/lightpanda/node.rb +92 -9
- data/lib/capybara/lightpanda/process.rb +96 -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: ad9d7f3b8897578e98e75f2b27f35ec4f2178c4ef884d9b69b79ad8d60378a42
|
|
4
|
+
data.tar.gz: ce602d8d708904f4f2f5dced507f838445fccb6693df8350779cf544c6871fa4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
+
# 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
|
-
|
|
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:
|
|
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
|
|
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
|
|
317
|
-
|
|
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::
|
|
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
|