capybara-lightpanda 0.14.0 → 0.14.2

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: 759abeb96b5d6898d9458d2e81af39b97d155766cfd2c80fa1ed363409a6aff6
4
- data.tar.gz: 66d54d836501ab8ec7d3cc23ffc389f58777c938a19490f9dc81a076819b4489
3
+ metadata.gz: 197930c8808e3cb4d58ce97952b13e657dfbf8e7d200a98b03a5e71e7afdc87f
4
+ data.tar.gz: 6401c206e53dce565b29eab2e4c4b01bbf141a88c81025bc12b584b7cc0e75af
5
5
  SHA512:
6
- metadata.gz: ffdc46e2b18c6ce822fb7765cc668bfda6594e1ce27f9ad88b29e4d9a34f6882b80aad54fcb0581d460e68b3fbcaaaa853017f825295b1a8758a244da520d12d
7
- data.tar.gz: 6ae51661f2a7108ec85bebf773965dd792f74d04b80cdff7c5992b41be5c32f15b3d7e08d97a64212a64a812be4ebe811f222ad6fa91866888d1f6e50a805b71
6
+ metadata.gz: 0cfe0bd2b2491e8b26715110950286a74e9bd1a7816d52a76b87cc601814ed4dd4faabf5665108f82624d6720581bdc7560c82445220456cb089a13313618857
7
+ data.tar.gz: 84fb972984583e315cf20f0a70a114238015aa29c49786a6aec5f329556518fc63ee5bb2204e430ccdb81e014943535cc2899c1ec40fb673d3f641bf5b201063
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.14.2] - 2026-10-14
4
+
5
+ ### Fixed
6
+
7
+ - **A browser that stops reading no longer hangs the test process.** If Lightpanda got stuck (a page in an endless synchronous loop, a stopped process) while a large command was being sent, about 1 MB and up (a big `execute_script` bundle, for example), the send blocked forever: the command's timeout never started, every other command waited behind it, and so did quitting the browser, at exit included. Sending now gives up after the command's timeout and raises `DeadBrowserError`.
8
+ - **Clicking a `mailto:` or `tel:` link keeps the page, as in Chrome.** On Lightpanda 1.0.0, clicking `<a href="mailto:someone@example.com">` left the page and loaded `example.com`, the host in the address. On nightlies 10261 to 10535, clicking a `tel:` link left the page unusable: every later call raised `NoExecutionContextError`. The page now stays and your own click handlers still run. The same applies to any link whose scheme the browser can't load (`sms:`, `ftp:`…).
9
+
10
+ ## [0.14.1] - 2026-10-12
11
+
12
+ ### Fixed
13
+
14
+ - **A click that loads a new page waits for that page's scripts.** `click` used to return as soon as the new document existed, before its deferred and module scripts had run. Apps load Stimulus, Turbo and Bootstrap that way, so the next action could land on a page whose handlers were not wired yet: a Bootstrap collapse that did not open, a `confirm()` that never showed (`accept_confirm` raised `ModalNotFound`) while the form was submitted anyway, a form posted before its JavaScript was ready. The click now also waits for `DOMContentLoaded`, as Cuprite waits for the load event. `send_keys`, `go_back` and `go_forward` wait the same way when they load a new page. Clicks that don't navigate are as fast as before.
15
+ - **A click on a link that Turbo hands back to the browser waits for the new page.** With Turbo on the page, such a click returned immediately on the page being left, because Turbo reported activity and went idle again before the navigation started.
16
+
3
17
  ## [0.14.0] - 2026-10-11
4
18
 
5
19
  ### Fixed
@@ -167,6 +167,7 @@ module Capybara
167
167
  subscribe_to_console_capture
168
168
  subscribe_to_execution_context
169
169
  subscribe_to_navigation
170
+ subscribe_to_document_ready
170
171
  subscribe_to_turbo_signals
171
172
  # Network owns the Network.* domain: enabling installs traffic
172
173
  # tracking AND the navigation-response capture behind status_code.
@@ -425,13 +426,15 @@ module Capybara
425
426
  #
426
427
  # Sniff window: the action returns synchronously, but the CDP events
427
428
  # signalling its async fallout arrive later on the dispatch thread. We
428
- # poll briefly for any of them — a main-frame navigation start (see
429
- # subscribe_to_navigation), Runtime.executionContextsCleared, or the
430
- # turbo sentinel — and if none fires within the window, assume the
431
- # action was inert and exit fast. Lightpanda announces a navigation
432
- # ~5 ms after the click but only swaps the execution context once the
433
- # response lands, so watching the context alone returned on the old
434
- # document whenever the server took longer than the window.
429
+ # poll briefly for either of them — a main-frame navigation start (see
430
+ # subscribe_to_navigation) or Runtime.executionContextsCleared — and if
431
+ # none fires within the window, assume the action was inert and exit
432
+ # fast. Lightpanda announces a navigation ~5 ms after the click but only
433
+ # swaps the execution context once the response lands, so watching the
434
+ # context alone returned on the old document whenever the server took
435
+ # longer than the window. The turbo sentinel does not end the sniff
436
+ # (it is awaited after): Turbo goes busy then idle within a few ms on a
437
+ # link it hands back to the browser, before the navigation start lands.
435
438
  SNIFF_WINDOW = 0.05
436
439
  private_constant :SNIFF_WINDOW
437
440
 
@@ -444,7 +447,6 @@ module Capybara
444
447
  navigating = @navigation_event.iteration > prior_navigation_iteration
445
448
  break if navigating
446
449
  break if @default_context_event.iteration > prior_context_iteration
447
- break unless @turbo_event.set?
448
450
  break if monotonic_time > sniff_deadline
449
451
 
450
452
  sleep 0.001
@@ -452,7 +454,10 @@ module Capybara
452
454
 
453
455
  # Only a navigation this action started is awaited: one left pending
454
456
  # by an earlier load must not make every later click pay the timeout.
455
- @navigation_event.wait(@options.timeout) if navigating
457
+ if navigating
458
+ @navigation_event.wait(@options.timeout)
459
+ wait_for_document_ready
460
+ end
456
461
  @default_context_event.wait(@options.timeout)
457
462
  @turbo_event.wait(@options.timeout)
458
463
  check_unhandled_modal!
@@ -658,6 +663,64 @@ module Capybara
658
663
  end
659
664
  end
660
665
 
666
+ # The new document's context exists before its deferred and module
667
+ # scripts have run: this event marks the end of that phase, at
668
+ # Page.domContentEventFired or anything later that proves it is over
669
+ # (load, the frame stopping, the document request failing).
670
+ def subscribe_to_document_ready
671
+ @document_ready_event = Utils::Event.new
672
+ @document_ready_event.set
673
+
674
+ on("Page.frameStartedNavigating") do |params|
675
+ @document_ready_event.reset if main_frame_navigation_start?(params)
676
+ end
677
+ on("Page.domContentEventFired") { @document_ready_event.set }
678
+ on("Page.loadEventFired") { @document_ready_event.set }
679
+ on("Page.frameStoppedLoading") do |params|
680
+ @document_ready_event.set if params["frameId"] == @target_id
681
+ end
682
+ on("Network.loadingFailed") do |params|
683
+ @document_ready_event.set if params["requestId"] == @navigation_request_id
684
+ end
685
+ end
686
+
687
+ # After a click that navigated, the next action must not run before the
688
+ # new page's JS has wired its handlers: Stimulus, Bootstrap's data-api
689
+ # and Turbo load as module scripts, which run after the document commits
690
+ # and before DOMContentLoaded. Cuprite waits for the load event. The
691
+ # event is awaited briefly, then document.readyState is polled for the
692
+ # rest of the budget, as #go_to does (lightpanda-io/browser#1801: page
693
+ # events may never come). Never raises: a page that never settles costs
694
+ # the timeout once, then the action returns as it did before.
695
+ DOCUMENT_READY_EVENT_WAIT = 2
696
+ private_constant :DOCUMENT_READY_EVENT_WAIT
697
+
698
+ def wait_for_document_ready
699
+ deadline = monotonic_time + @options.timeout
700
+ return if @document_ready_event.wait([DOCUMENT_READY_EVENT_WAIT, @options.timeout].min)
701
+
702
+ remaining = deadline - monotonic_time
703
+ return unless remaining.positive?
704
+
705
+ Utils::Wait.until(timeout: remaining, interval: 0.1) do
706
+ @document_ready_event.set? || @client.closed? || document_complete?
707
+ end
708
+ rescue TimeoutError
709
+ nil
710
+ end
711
+
712
+ def document_complete?
713
+ response = @client.command(
714
+ "Runtime.evaluate",
715
+ { expression: "document.readyState", returnByValue: true },
716
+ session_id: @session_id,
717
+ timeout: 2
718
+ )
719
+ response.dig("result", "value") == "complete"
720
+ rescue Error
721
+ false
722
+ end
723
+
661
724
  def main_frame_navigation_start?(params)
662
725
  params["frameId"] == @target_id && !params["navigationType"].to_s.match?(/same/i)
663
726
  end
@@ -26,11 +26,18 @@ module Capybara
26
26
  connect
27
27
  end
28
28
 
29
- def send_message(message)
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
32
  raise DeadBrowserError, "WebSocket is not open" unless @status == :open
31
33
 
32
34
  @logger&.puts("\n\n▶ #{@logger.elapsed_time} #{message}")
33
- @driver_mutex.synchronize { @driver.text(message) }
35
+ @driver_mutex.synchronize do
36
+ @write_timeout = timeout
37
+ @driver.text(message)
38
+ ensure
39
+ @write_timeout = nil
40
+ end
34
41
  end
35
42
 
36
43
  # The socket is closed even when the connection is already dead. A
@@ -53,8 +60,25 @@ module Capybara
53
60
  @status == :closed || @status == :error
54
61
  end
55
62
 
63
+ # Called by websocket-driver, always under @driver_mutex. A browser
64
+ # that stops reading (hung, stopped, or a page stuck in a sync JS loop
65
+ # on Lightpanda's single thread) would block a plain write forever
66
+ # once the frame outgrows the socket buffers, holding the lock that
67
+ # 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.
56
73
  def write(data)
57
- @socket.write(data)
74
+ until data.empty?
75
+ written = @socket.write_nonblock(data, exception: false)
76
+ if written.is_a?(Integer)
77
+ data = data.byteslice(written..)
78
+ elsif !wait_for_socket(written)
79
+ abandon_stalled_write
80
+ end
81
+ end
58
82
  rescue Errno::EPIPE, Errno::ECONNRESET, IOError
59
83
  mark_dead
60
84
  end
@@ -71,6 +95,21 @@ module Capybara
71
95
 
72
96
  private
73
97
 
98
+ def wait_for_socket(state)
99
+ timeout = @write_timeout || @options.timeout
100
+ state == :wait_readable ? @socket.wait_readable(timeout) : @socket.wait_writable(timeout)
101
+ end
102
+
103
+ # The frame was cut off midway, so the connection can't be reused:
104
+ # mark it dead (the reader and message threads stop, pending commands
105
+ # are released) and close the socket.
106
+ def abandon_stalled_write
107
+ timeout = @write_timeout || @options.timeout
108
+ mark_dead(:error)
109
+ close_socket
110
+ raise DeadBrowserError, "Browser accepted no data for #{timeout}s and is likely stuck; connection closed"
111
+ end
112
+
74
113
  def close_socket
75
114
  @socket&.close unless @socket.nil? || @socket.closed?
76
115
  rescue IOError, SystemCallError
@@ -22,18 +22,18 @@ module Capybara
22
22
 
23
23
  def command(method, params = {}, async: false, session_id: nil, timeout: nil)
24
24
  message = build_message(method, params, session_id: session_id)
25
+ effective_timeout = timeout || @options.timeout
25
26
 
26
27
  if async
27
- @ws.send_message(JSON.generate(message))
28
+ @ws.send_message(JSON.generate(message), timeout: effective_timeout)
28
29
  return true
29
30
  end
30
31
 
31
32
  pending = Concurrent::IVar.new
32
33
  @pendings[message[:id]] = pending
33
34
 
34
- @ws.send_message(JSON.generate(message))
35
+ @ws.send_message(JSON.generate(message), timeout: effective_timeout)
35
36
 
36
- effective_timeout = timeout || @options.timeout
37
37
  response = pending.value!(effective_timeout)
38
38
 
39
39
  if response.nil?
@@ -638,8 +638,27 @@ module Capybara
638
638
  }
639
639
  hit.dispatchEvent(new EventCtor('mouseup', { bubbles: true, cancelable: true }));
640
640
  var clickEvt = new EventCtor('click', { bubbles: true, cancelable: true });
641
- var notCancelled = hit.dispatchEvent(clickEvt);
642
- if (!notCancelled || clickEvt.defaultPrevented) return;
641
+ // A link to a scheme the browser can't fetch (mailto:, tel:, sms:…)
642
+ // goes to an external app in Chrome and the page stays. Below
643
+ // upstream #3940 (build 10536) the link's own activation instead
644
+ // turns `mailto:a@b.com` into `http://mailto:a@b.com/` and loads
645
+ // b.com (1.0.0), or loses the execution context on a failed `tel:`
646
+ // navigation (10261–10535). Cancel that default from a window
647
+ // listener added for this one dispatch: it runs after the page's
648
+ // own listeners, which still see an uncancelled click.
649
+ var link = hit.closest ? hit.closest('a[href]') : null;
650
+ var scheme = link && /^\\s*([a-z][a-z0-9+.-]*):/i.exec(link.getAttribute('href'));
651
+ var external = !!scheme && !/^(https?|about|blob|data|file|javascript)$/i.test(scheme[1]);
652
+ var view = hit.ownerDocument.defaultView;
653
+ var keepPage = function (e) { if (e === clickEvt) e.preventDefault(); };
654
+ if (external && view) view.addEventListener('click', keepPage);
655
+ var notCancelled;
656
+ try {
657
+ notCancelled = hit.dispatchEvent(clickEvt);
658
+ } finally {
659
+ if (external && view) view.removeEventListener('click', keepPage);
660
+ }
661
+ if (external || !notCancelled || clickEvt.defaultPrevented) return;
643
662
  var tag = hit.tagName;
644
663
  // A `javascript:` link never navigates: the click's own activation
645
664
  // behavior has already run its code. Assigning it to location.href
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Lightpanda
5
- VERSION = "0.14.0"
5
+ VERSION = "0.14.2"
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.14.0
4
+ version: 0.14.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Navid Emad