capybara-lightpanda 0.16.0 → 0.17.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: a8f6c1cfdad13de4569b3d79828d9d19848b166e220b6a91329c7c0cb2fa9781
4
- data.tar.gz: 2f7c90bb35b746682f5533c9d7ebfe1b64fbf7aea1f53a6c93f563428d9f3b46
3
+ metadata.gz: c3e05fa2b4d3b676fc6dfdd88fb5c4c65b7c2d84ae979a3c6b2cc6bcfddbd712
4
+ data.tar.gz: ded427c3ae0148dc351d7b43ade13daf7c4f8aebc1c6492c0480b5a146d116f9
5
5
  SHA512:
6
- metadata.gz: 1d1632dc29185af1e1d1c594abd7a9fe4b0af584585aa6c6534428d59b23316174db581d901bd7d67430b2e708c664c9c7175918cfeca4d386ba95453816b910
7
- data.tar.gz: 76a79eed2e960c294a09c1ee8882014d543c5695cd519d51c3d9560ca7b153b085ae4161f18e2a11a301465b95c0cc71140a269c3c8970c86e93d291430630cb
6
+ metadata.gz: a7d53b5fa0e8d971ca16b9d82a96b65c313e1663313775959b226f97d530e8c84ee19d33b4ec085983bb5d659ff262de97aa6d2501b24ffe0df25755589dae9b
7
+ data.tar.gz: c2bf56759b971c045a46a589c0b6bcfa31ee2de84c6f995b62731c973b99dec8b03fb9d10f22c4e4a445673ae3c1659ddfc87dd2ea19a5a32ccc9491097993d6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.17.0] - 2026-11-05
4
+
5
+ > **Update Lightpanda before upgrading.** This release requires Lightpanda **1.0.3** or a nightly build ≥ 11647. The driver refuses anything older and names the version it found. Pin `1.0.3` for a reproducible browser.
6
+
7
+ ### Changed
8
+
9
+ - **`<a download>` links download natively on Lightpanda 1.0.3.** The driver used to wait for a 1.1 release before letting the browser handle a link's `download` attribute itself, so on 1.0.3 it also went to the link's URL. Every supported browser now handles it natively, and the driver leaves the download to it.
10
+
11
+ ### Fixed
12
+
13
+ - **`reset_session!` no longer starts a browser just to reset it.** Resetting a session that never opened a page used to launch Lightpanda first. When that launch failed, the reset waited for the full process timeout a second time.
14
+ - **Contenteditable elements are detected as browsers detect them.** `contenteditable="plaintext-only"` now counts as editable. An unrecognised value such as `contenteditable="bogus"` now inherits from its parent instead of making the element editable. A page with `document.designMode = "on"` is editable throughout. SVG elements are ignored. This affects which element a click focuses and where `send_keys` types.
15
+ - **A stalled browser can no longer hang a command past its timeout.** The timeout used to cover only the wait for the reply, so a browser that had stopped reading could hold the call much longer while it was still being sent. Sending and waiting now share one deadline.
16
+
3
17
  ## [0.16.0] - 2026-10-26
4
18
 
5
19
  ### Fixed
data/README.md CHANGED
@@ -57,7 +57,7 @@ driven_by :lightpanda
57
57
  > Lightpanda is a headless agentic browser, not a layout engine. External `<link rel="stylesheet">` **are** fetched and applied (the gem enables this by default), and `@media` / `window.matchMedia()` evaluate against the `window_size` you configure — so a mobile-only CTA gated by `@media (max-width: …)` resolves at the width you ask for. What's missing is *layout*: nothing reflows, `getBoundingClientRect` stays synthetic, and there is no real scroll. Specs that assert on pixel geometry, scrolling, or screenshots can't pass here, nor can the two that catch people out: a second browser tab and a menu revealed purely by CSS `:hover`. See [known limitations](https://navidemad.github.io/capybara-lightpanda/docs/#limits).
58
58
 
59
59
  > [!TIP]
60
- > For reproducible CI, pin the browser: `Capybara::Lightpanda::Binary.required_version = "1.0.2"` (the tagged release the gem's current floor is pinned to — nightly build 10965 is that release's commit). Without a pin the driver tracks Lightpanda's rolling `nightly` tag, which moves under you. See [Pinning the browser version](https://navidemad.github.io/capybara-lightpanda/docs/#pinning).
60
+ > For reproducible CI, pin the browser: `Capybara::Lightpanda::Binary.required_version = "1.0.3"` (the tagged release the gem's current floor is pinned to — nightly build 11647 is that release's commit). Without a pin the driver tracks Lightpanda's rolling `nightly` tag, which moves under you. See [Pinning the browser version](https://navidemad.github.io/capybara-lightpanda/docs/#pinning).
61
61
 
62
62
  ## Credits
63
63
 
@@ -5,6 +5,7 @@ require "socket"
5
5
  require "websocket/driver"
6
6
 
7
7
  require_relative "../utils/attempt"
8
+ require_relative "../utils/wait"
8
9
 
9
10
  module Capybara
10
11
  module Lightpanda
@@ -26,17 +27,27 @@ module Capybara
26
27
  connect
27
28
  end
28
29
 
29
- # `timeout` bounds how long the browser may go without taking any of
30
- # the frame's bytes (see #write); nil falls back to options.timeout.
31
- def send_message(message, timeout: nil)
30
+ # The frame must be sent by `deadline` (monotonic seconds; Client#command
31
+ # passes the command's own deadline) or within `timeout` seconds; both
32
+ # nil fall back to options.timeout. A frame still queued behind the
33
+ # socket lock at the deadline wrote nothing, so it raises TimeoutError
34
+ # and the connection stays usable; one cut off mid-write kills the
35
+ # connection (see #write).
36
+ def send_message(message, timeout: nil, deadline: nil)
32
37
  raise DeadBrowserError, "WebSocket is not open" unless @status == :open
33
38
 
39
+ deadline ||= Utils::Wait.deadline(timeout || @options.timeout)
34
40
  @logger&.puts("\n\n▶ #{@logger.elapsed_time} #{message}")
35
- @driver_mutex.synchronize do
36
- @write_timeout = timeout
41
+ unless acquire_driver_lock?(deadline)
42
+ raise TimeoutError, "Frame not sent before its deadline: another frame held the socket"
43
+ end
44
+
45
+ begin
46
+ @write_deadline = deadline
37
47
  @driver.text(message)
38
48
  ensure
39
- @write_timeout = nil
49
+ @write_deadline = nil
50
+ @driver_mutex.unlock
40
51
  end
41
52
  end
42
53
 
@@ -65,11 +76,11 @@ module Capybara
65
76
  # on Lightpanda's single thread) would block a plain write forever
66
77
  # once the frame outgrows the socket buffers, holding the lock that
67
78
  # every other command, the reader's parse and #close need — and the
68
- # command timeout only starts after the write returns. So the write
69
- # gives up once the browser takes no byte for the timeout; the wait
70
- # restarts on every partial write, so a slow but moving peer is fine
71
- # (ferrum #644). Close and pong frames have no command timeout and
72
- # fall back to options.timeout rather than waiting forever.
79
+ # command would never time out. So the write gives up at the frame's
80
+ # deadline even when the peer is still taking bytes: a trickling peer
81
+ # must not hold the lock past the caller's budget (ferrum #644). Close
82
+ # and pong frames have no command deadline; they give up once the
83
+ # browser takes no byte for options.timeout.
73
84
  def write(data)
74
85
  until data.empty?
75
86
  written = @socket.write_nonblock(data, exception: false)
@@ -96,7 +107,9 @@ module Capybara
96
107
  private
97
108
 
98
109
  def wait_for_socket(state)
99
- timeout = @write_timeout || @options.timeout
110
+ timeout = @write_deadline ? Utils::Wait.remaining(@write_deadline) : @options.timeout
111
+ return false if timeout <= 0
112
+
100
113
  state == :wait_readable ? @socket.wait_readable(timeout) : @socket.wait_writable(timeout)
101
114
  end
102
115
 
@@ -104,10 +117,28 @@ module Capybara
104
117
  # mark it dead (the reader and message threads stop, pending commands
105
118
  # are released) and close the socket.
106
119
  def abandon_stalled_write
107
- timeout = @write_timeout || @options.timeout
120
+ reason = if @write_deadline
121
+ "Browser did not take the whole frame before the command's deadline"
122
+ else
123
+ "Browser accepted no data for #{@options.timeout}s"
124
+ end
108
125
  mark_dead(:error)
109
126
  close_socket
110
- raise DeadBrowserError, "Browser accepted no data for #{timeout}s and is likely stuck; connection closed"
127
+ raise DeadBrowserError, "#{reason} and is likely stuck; connection closed"
128
+ end
129
+
130
+ # Mutex has no timed lock. Poll try_lock with a short capped backoff:
131
+ # the lock is normally held for one frame write or one parse.
132
+ def acquire_driver_lock?(deadline)
133
+ delay = 0.0005
134
+ until @driver_mutex.try_lock
135
+ remaining = Utils::Wait.remaining(deadline)
136
+ return false if remaining <= 0
137
+
138
+ sleep([delay, remaining].min)
139
+ delay = [delay * 2, 0.01].min
140
+ end
141
+ true
111
142
  end
112
143
 
113
144
  def close_socket
@@ -20,21 +20,26 @@ module Capybara
20
20
  start_message_thread
21
21
  end
22
22
 
23
+ # `timeout` bounds the whole command: waiting for the socket lock, writing
24
+ # the frame and waiting for the response all draw on one deadline, so a
25
+ # slow send can't stretch a command past its budget.
23
26
  def command(method, params = {}, async: false, session_id: nil, timeout: nil)
24
27
  message = build_message(method, params, session_id: session_id)
25
28
  effective_timeout = timeout || @options.timeout
29
+ deadline = Utils::Wait.deadline(effective_timeout)
26
30
 
27
31
  if async
28
- @ws.send_message(JSON.generate(message), timeout: effective_timeout)
32
+ @ws.send_message(JSON.generate(message), deadline: deadline)
29
33
  return true
30
34
  end
31
35
 
32
36
  pending = Concurrent::IVar.new
33
37
  @pendings[message[:id]] = pending
34
38
 
35
- @ws.send_message(JSON.generate(message), timeout: effective_timeout)
39
+ @ws.send_message(JSON.generate(message), deadline: deadline)
36
40
 
37
- response = pending.value!(effective_timeout)
41
+ # value!(0) still returns a response that landed during the send.
42
+ response = pending.value!(Utils::Wait.remaining(deadline))
38
43
 
39
44
  if response.nil?
40
45
  raise DeadBrowserError, "Browser closed during #{method}" if @ws.closed?
@@ -13,8 +13,8 @@ module Capybara
13
13
  # a blocking `wait`, the way ferrum's Downloads does.
14
14
  #
15
15
  # Navigation downloads require Content-Disposition: attachment, not just a
16
- # MIME type. New browsers also honor an anchor's download attribute (build
17
- # 11097 / 1.1 release line); Node#click lets that native activation finish.
16
+ # MIME type. The floor also honors an anchor's download attribute (upstream
17
+ # #4124, 11097); Node#click lets that native activation finish.
18
18
  # Capybara's MIME-only :download shared spec still stays in capybara_skip.
19
19
  #
20
20
  # Structure deliberately mirrors Network (same browser ref, same mutex +
@@ -322,7 +322,14 @@ module Capybara
322
322
  # turns programmer errors (e.g. a NoMethodError in browser.rb) into a
323
323
  # silent quit-and-respawn on every example with zero signal. The warn
324
324
  # keeps repeated respawns visible.
325
+ #
326
+ # With no browser (never visited, or the launch itself failed) there is
327
+ # nothing to reset: `browser` would spawn Lightpanda just to reset it,
328
+ # and a failed launch would cost a second process timeout in teardown.
329
+ # The next visit launches as usual.
325
330
  def reset!
331
+ return unless @browser
332
+
326
333
  browser.reset
327
334
  rescue Error, SystemCallError, IOError => e
328
335
  warn "[capybara-lightpanda] reset! failed (#{e.class}: #{e.message}); respawning browser"
@@ -11,6 +11,7 @@ window._lightpanda = {
11
11
  isVisible: isVisible,
12
12
  isObscured: isObscured,
13
13
  isDisabled: isDisabled,
14
+ contentEditableState: contentEditableState,
14
15
  isContentEditable: isContentEditable,
15
16
  pointerFocusTarget: pointerFocusTarget,
16
17
  visibleText: visibleText,
@@ -92,19 +92,32 @@ function isDisabled(el) {
92
92
  return false;
93
93
  }
94
94
 
95
- // True if the element is the host of a contenteditable region: it (or any
96
- // ancestor) has a non-"false" `contenteditable` attribute. Lightpanda's
97
- // native `HTMLElement.isContentEditable` (PR #2310) is hardwired to return
98
- // `false` for every element — it has no caret/keyboard editing pipeline —
99
- // so the IDL property is useless here and we walk ancestors ourselves.
95
+ // The element's own contenteditable state: true for the "", "true" and
96
+ // "plaintext-only" values, false for "false", null for inherit (no attribute,
97
+ // an invalid value, or a non-HTML element — the attribute is HTML-only).
98
+ // Mirrors upstream Element.contentEditableState.
99
+ function contentEditableState(el) {
100
+ if (el.namespaceURI !== 'http://www.w3.org/1999/xhtml') return null;
101
+ if (!el.hasAttribute || !el.hasAttribute('contenteditable')) return null;
102
+ var v = (el.getAttribute('contenteditable') || '').toLowerCase();
103
+ if (v === '' || v === 'true' || v === 'plaintext-only') return true;
104
+ if (v === 'false') return false;
105
+ return null;
106
+ }
107
+
108
+ // True if the element can be edited: the nearest contenteditable attribute in
109
+ // a true or false state decides, then the document's designMode. The native
110
+ // `isContentEditable` agrees from upstream #4210 (11229, in the floor; checked
111
+ // on 1.0.3), but SVG elements have no such getter and pointerFocusTarget needs
112
+ // each element's own state, so the walk stays.
100
113
  function isContentEditable(el) {
101
- var n = el;
102
- while (n && n.nodeType === 1) {
103
- if (n.hasAttribute && n.hasAttribute('contenteditable')) {
104
- var v = (n.getAttribute('contenteditable') || '').toLowerCase();
105
- return v !== 'false';
114
+ for (var n = el; n; n = n.parentNode) {
115
+ if (n.nodeType === 1) {
116
+ var state = contentEditableState(n);
117
+ if (state !== null) return state;
118
+ } else if (n.nodeType === 9) {
119
+ return String(n.designMode).toLowerCase() === 'on';
106
120
  }
107
- n = n.parentElement;
108
121
  }
109
122
  return false;
110
123
  }
@@ -116,9 +129,15 @@ function isContentEditable(el) {
116
129
  // only kind CLICK_JS can dispatch — never runs the focus default, so the
117
130
  // driver has to apply it itself or `click` then `page.send_keys` types nowhere.
118
131
  function pointerFocusTarget(el) {
132
+ // A contenteditable attribute in the true state, or the body of a
133
+ // designMode document (Chrome edits and focuses the body, not <html>).
119
134
  function isEditingHost(n) {
120
- if (!n.hasAttribute || !n.hasAttribute('contenteditable')) return false;
121
- return (n.getAttribute('contenteditable') || '').toLowerCase() !== 'false';
135
+ var state = contentEditableState(n);
136
+ if (state !== null) return state;
137
+ var root = n.parentNode, doc = root && root.parentNode;
138
+ return (n.tagName || '').toUpperCase() === 'BODY' &&
139
+ (root.tagName || '').toUpperCase() === 'HTML' &&
140
+ !!doc && doc.nodeType === 9 && String(doc.designMode).toLowerCase() === 'on';
122
141
  }
123
142
  function isFocusable(n) {
124
143
  if (isDisabled(n)) return false;
@@ -135,7 +135,7 @@ module Capybara
135
135
  end
136
136
 
137
137
  def click(_keys = [], **_options)
138
- call(CLICK_JS, native_anchor_downloads?)
138
+ call(CLICK_JS)
139
139
  driver.browser.wait_for_idle
140
140
  end
141
141
 
@@ -376,18 +376,6 @@ module Capybara
376
376
 
377
377
  private
378
378
 
379
- # Below #4124 a download attribute suppresses native activation entirely,
380
- # so the manual navigation is still needed for attachment responses.
381
- # The API has no feature marker: keep the floor path through 1.0.x and
382
- # enable native download activation on build 11097 / the 1.1 release line.
383
- def native_anchor_downloads?
384
- browser = driver.browser
385
- build = browser.nightly_build
386
- return build >= Gem::Version.new("11097") if build
387
-
388
- browser.release && browser.release >= Gem::Version.new("1.1.0")
389
- end
390
-
391
379
  def find_hinted(method, selector, hints)
392
380
  driver.browser.find_within_hinted(@remote_object_id, method, selector, self.class.hint_spec(**hints))
393
381
  .map { |oid, cache| self.class.new(driver, oid, cache, frame_scoped: @frame_scoped) }
@@ -607,7 +595,7 @@ module Capybara
607
595
  # 6167 as duplicate `turbo:submit-start` events; the first request
608
596
  # gets aborted by the second and Turbo never renders the response.
609
597
  CLICK_JS = <<~JS
610
- function(nativeAnchorDownloads) {
598
+ function() {
611
599
  var EventCtor = (typeof MouseEvent !== 'undefined') ? MouseEvent : Event;
612
600
  // Emulate coordinate hit-testing without a layout engine. In a real
613
601
  // browser a pointer click on a container lands on its frontmost
@@ -672,11 +660,11 @@ module Capybara
672
660
  var external = !!scheme && !/^(https?|about|blob|data|file|javascript)$/i.test(scheme[1]);
673
661
  var notCancelled = hit.dispatchEvent(clickEvt);
674
662
  if (external || !notCancelled || clickEvt.defaultPrevented) return;
675
- // Native activation has already started the download. Navigating
676
- // again cancels it and replaces the document. Keep the complete
677
- // synthetic event sequence and its cancellation, but stop here on
678
- // browsers that support the download attribute.
679
- if (nativeAnchorDownloads && link && link.hasAttribute('download')) return;
663
+ // Native activation has already started the download (upstream
664
+ // #4124, 11097, in the floor). Navigating again cancels it and
665
+ // replaces the document. Keep the complete synthetic event sequence
666
+ // and its cancellation, but stop here.
667
+ if (link && link.hasAttribute('download')) return;
680
668
  var tag = hit.tagName;
681
669
  // A `javascript:` link never navigates: the click's own activation
682
670
  // behavior has already run its code. Assigning it to location.href
@@ -302,7 +302,19 @@ module Capybara
302
302
  # #3658 (10657) client rects follow the scroll and
303
303
  # DOM.scrollIntoViewIfNeeded is real — HTML5_DRAG_JS centers the
304
304
  # drop target itself because scrollIntoView ignores `block`.
305
- MINIMUM_NIGHTLY_BUILD = Gem::Version.new("10965")
305
+ #
306
+ # 2026-11-05 bump 10965 -> 11647 (= release 1.0.3, the new lockstep cap;
307
+ # the bad-port block #4430 at 11651, CORS exposed headers #4242 at 11663
308
+ # and the x86_64 macOS CPU baseline #4450 at 11672 stay above it).
309
+ # Retired or ungated by it:
310
+ # #4124 (11097) native anchor downloads — Node#click no longer asks a
311
+ # version gate before skipping its own navigation for a download
312
+ # link; that gate wanted release 1.1.0 although 1.0.3 carries it.
313
+ # Rides along: CSS nesting #3962 (10976), blank-page timers #4148
314
+ # (11012), ShadowRoot hit testing #4184 (11071), same-document
315
+ # navigateToHistoryEntry #3903 (11113), native isContentEditable #4210
316
+ # (11229), contenteditable Backspace/Delete/Ctrl+A #4415 (11610).
317
+ MINIMUM_NIGHTLY_BUILD = Gem::Version.new("11647")
306
318
 
307
319
  # Lines of browser output #output keeps (ferrum keeps the same 100).
308
320
  OUTPUT_LINES = 100
@@ -314,16 +326,16 @@ module Capybara
314
326
  # bare "0.3.6" carrying no commit counter at all, and MINIMUM_NIGHTLY_BUILD
315
327
  # has nothing to compare against. Releases are cut from the same trunk, so
316
328
  # a release is acceptable exactly when its own commit count clears the
317
- # nightly floor. 1.0.2 (2026-10-23) is build 10965 — exactly the nightly
329
+ # nightly floor. 1.0.3 (2026-11-05) is build 11647 — exactly the nightly
318
330
  # floor, which is no accident: the floor was raised *to* the release so
319
- # both channels gate the same commit. (1.0.1 = 10641 predates fixes the
331
+ # both channels gate the same commit. (1.0.2 = 10965 predates fixes the
320
332
  # floor exists for.)
321
333
  #
322
334
  # INVARIANT: every MINIMUM_NIGHTLY_BUILD bump must also move this to the
323
335
  # first release containing that build (`git rev-list --count <tag>` in the
324
336
  # browser repo tells you). Leaving it behind would let the release channel
325
337
  # silently accept a binary the nightly channel rejects.
326
- MINIMUM_RELEASE = Gem::Version.new("1.0.2")
338
+ MINIMUM_RELEASE = Gem::Version.new("1.0.3")
327
339
 
328
340
  class << self
329
341
  # `lightpanda version` prints one of two shapes, and the gem supports
@@ -42,6 +42,16 @@ module Capybara
42
42
  def self.monotonic_time
43
43
  ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
44
44
  end
45
+
46
+ # A point `seconds` from now on the monotonic clock.
47
+ def self.deadline(seconds)
48
+ monotonic_time + seconds
49
+ end
50
+
51
+ # Seconds left before `deadline`, never negative.
52
+ def self.remaining(deadline)
53
+ [deadline - monotonic_time, 0].max
54
+ end
45
55
  end
46
56
  end
47
57
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Lightpanda
5
- VERSION = "0.16.0"
5
+ VERSION = "0.17.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.16.0
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Navid Emad