capybara-lightpanda 0.15.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: bcfaa2e5c56f74e8f6068a969ee03d669d11d49de80ae360fb4a62a96833c8f3
4
- data.tar.gz: 3a22106094a7f2de8c3447f9573286e90a4158f25f88b06701cf4540ab9e2147
3
+ metadata.gz: c3e05fa2b4d3b676fc6dfdd88fb5c4c65b7c2d84ae979a3c6b2cc6bcfddbd712
4
+ data.tar.gz: ded427c3ae0148dc351d7b43ade13daf7c4f8aebc1c6492c0480b5a146d116f9
5
5
  SHA512:
6
- metadata.gz: ebf7c506245fdc550ff5ac3bc643dfe6369c4324642bffe8fb4a27f891f009eaaa842effe0bb7386dfc6d50819ad934d0a3df0e7f67e4fb222116baa5a957955
7
- data.tar.gz: 2aaef86e3c5a6c1a4e25c3476790352564df15c0b46c18c02745b5999e587a35090ff88837340dec8e00f977a828ec3ce1eaf9a505b10abfb0412e2538cf4d13
6
+ metadata.gz: a7d53b5fa0e8d971ca16b9d82a96b65c313e1663313775959b226f97d530e8c84ee19d33b4ec085983bb5d659ff262de97aa6d2501b24ffe0df25755589dae9b
7
+ data.tar.gz: c2bf56759b971c045a46a589c0b6bcfa31ee2de84c6f995b62731c973b99dec8b03fb9d10f22c4e4a445673ae3c1659ddfc87dd2ea19a5a32ccc9491097993d6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
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
+
17
+ ## [0.16.0] - 2026-10-26
18
+
19
+ ### Fixed
20
+
21
+ - **`trigger(:click)` waits for the page it loads.** Calling `element.trigger(:click)` on a link or submit button returned before the new page arrived, so the next step ran against the old page. It now waits the way `click` does.
22
+ - **Clicking a `<a download>` link no longer cancels the download on recent nightlies.** From nightly 11097 (and the upcoming 1.1 release line), Lightpanda starts the download itself when a link has a `download` attribute. The driver then also went to the same URL, which cancelled the download and replaced the page. The driver now leaves the download to the browser. Older browsers behave as before.
23
+ - **`obscured?` sees overlays inside a shadow root.** On browsers that can hit-test inside shadow trees (recent nightlies), an element covered by another element in the same shadow root now counts as obscured; before, it was always reported as not obscured. Older browsers give the same answer as before.
24
+
3
25
  ## [0.15.0] - 2026-10-23
4
26
 
5
27
  > **Update Lightpanda before upgrading.** This release requires Lightpanda **1.0.2** or a nightly build ≥ 10965. The driver refuses anything older and names the version it found. Pin `1.0.2` for a reproducible browser.
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?
@@ -12,10 +12,10 @@ module Capybara
12
12
  # `Browser.downloadProgress`. We mirror those into a completed-files list +
13
13
  # a blocking `wait`, the way ferrum's Downloads does.
14
14
  #
15
- # IMPORTANT: the trigger is `Content-Disposition: attachment`, NOT MIME
16
- # type. A `text/csv` (or any) response without that header is rendered as a
17
- # normal navigation, not downloaded — which is why Capybara's MIME-triggered
18
- # `:download` shared spec stays in capybara_skip.
15
+ # Navigation downloads require Content-Disposition: attachment, not just a
16
+ # MIME type. The floor also honors an anchor's download attribute (upstream
17
+ # #4124, 11097); Node#click lets that native activation finish.
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 +
21
21
  # subscribe/unsubscribe lifecycle, same reset-after-disposeBrowserContext
@@ -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,
@@ -54,17 +54,24 @@ function isObscured(el) {
54
54
  if (cx < 0 || cy < 0 || cx > w || cy > h) return true;
55
55
  var hit = doc.elementFromPoint(cx, cy);
56
56
  if (!hit) return true;
57
- if (hit === el) return false;
58
- if (el.contains(hit)) return false;
59
- // A hit inside a shadow tree is retargeted to its host, and there is no
60
- // ShadowRoot.elementFromPoint to look further, so landing on one of el's own
61
- // hosts is the best "not obscured" answer available.
62
- var root = el.getRootNode ? el.getRootNode() : doc;
57
+ // Document hit testing retargets a shadow child to its outermost host.
58
+ // Check each enclosing shadow tree from the outside in: an inner root must
59
+ // never let us see through an overlay covering its host in an outer root.
60
+ var roots = [], root = el.getRootNode ? el.getRootNode() : doc;
63
61
  while (root && root !== doc && root.host) {
64
- if (hit === root.host) return false;
62
+ roots.push(root);
65
63
  root = root.host.getRootNode();
66
64
  }
67
- return true;
65
+ for (var i = roots.length - 1; i >= 0; i--) {
66
+ root = roots[i];
67
+ if (hit !== root.host) break;
68
+ // Older supported builds have no shadow hit testing. A hit on our own
69
+ // host remains the best available evidence that the child is actionable.
70
+ if (typeof root.elementFromPoint !== 'function') return false;
71
+ hit = root.elementFromPoint(cx, cy);
72
+ if (!hit) return true;
73
+ }
74
+ return hit !== el && !el.contains(hit);
68
75
  }
69
76
 
70
77
  // Capybara's `Node#disabled?` is more permissive than CSS `:disabled`:
@@ -85,19 +92,32 @@ function isDisabled(el) {
85
92
  return false;
86
93
  }
87
94
 
88
- // True if the element is the host of a contenteditable region: it (or any
89
- // ancestor) has a non-"false" `contenteditable` attribute. Lightpanda's
90
- // native `HTMLElement.isContentEditable` (PR #2310) is hardwired to return
91
- // `false` for every element — it has no caret/keyboard editing pipeline —
92
- // 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.
93
113
  function isContentEditable(el) {
94
- var n = el;
95
- while (n && n.nodeType === 1) {
96
- if (n.hasAttribute && n.hasAttribute('contenteditable')) {
97
- var v = (n.getAttribute('contenteditable') || '').toLowerCase();
98
- 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';
99
120
  }
100
- n = n.parentElement;
101
121
  }
102
122
  return false;
103
123
  }
@@ -109,9 +129,15 @@ function isContentEditable(el) {
109
129
  // only kind CLICK_JS can dispatch — never runs the focus default, so the
110
130
  // driver has to apply it itself or `click` then `page.send_keys` types nowhere.
111
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>).
112
134
  function isEditingHost(n) {
113
- if (!n.hasAttribute || !n.hasAttribute('contenteditable')) return false;
114
- 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';
115
141
  }
116
142
  function isFocusable(n) {
117
143
  if (isDisabled(n)) return false;
@@ -178,7 +178,9 @@ module Capybara
178
178
  # generic Event for everything else (so callers can fire custom events
179
179
  # like `node.trigger('lp:custom')`).
180
180
  def trigger(event)
181
- call(TRIGGER_JS, event.to_s)
181
+ name = event.to_s
182
+ call(TRIGGER_JS, name)
183
+ driver.browser.wait_for_idle if name == "click"
182
184
  end
183
185
 
184
186
  def set(value, **_options)
@@ -658,6 +660,11 @@ module Capybara
658
660
  var external = !!scheme && !/^(https?|about|blob|data|file|javascript)$/i.test(scheme[1]);
659
661
  var notCancelled = hit.dispatchEvent(clickEvt);
660
662
  if (external || !notCancelled || clickEvt.defaultPrevented) 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;
661
668
  var tag = hit.tagName;
662
669
  // A `javascript:` link never navigates: the click's own activation
663
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.15.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.15.0
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Navid Emad