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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 197930c8808e3cb4d58ce97952b13e657dfbf8e7d200a98b03a5e71e7afdc87f
|
|
4
|
+
data.tar.gz: 6401c206e53dce565b29eab2e4c4b01bbf141a88c81025bc12b584b7cc0e75af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
429
|
-
# subscribe_to_navigation)
|
|
430
|
-
#
|
|
431
|
-
#
|
|
432
|
-
#
|
|
433
|
-
#
|
|
434
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
642
|
-
|
|
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
|