capybara-lightpanda 0.7.0 → 0.9.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 +48 -0
- data/lib/capybara/lightpanda/auto_scripts.rb +28 -2
- data/lib/capybara/lightpanda/binary.rb +49 -51
- data/lib/capybara/lightpanda/browser/console.rb +108 -0
- data/lib/capybara/lightpanda/browser/finder.rb +196 -0
- data/lib/capybara/lightpanda/browser/modals.rb +99 -0
- data/lib/capybara/lightpanda/browser/navigation.rb +185 -0
- data/lib/capybara/lightpanda/browser/runtime.rb +258 -0
- data/lib/capybara/lightpanda/browser.rb +65 -780
- data/lib/capybara/lightpanda/client/subscriber.rb +2 -0
- data/lib/capybara/lightpanda/client/web_socket.rb +19 -21
- data/lib/capybara/lightpanda/client.rb +5 -4
- data/lib/capybara/lightpanda/cookies.rb +2 -0
- data/lib/capybara/lightpanda/driver.rb +44 -36
- data/lib/capybara/lightpanda/errors.rb +19 -10
- data/lib/capybara/lightpanda/javascripts/attach.js +16 -0
- data/lib/capybara/lightpanda/javascripts/banner.js +15 -0
- data/lib/capybara/lightpanda/javascripts/predicates.js +152 -0
- data/lib/capybara/lightpanda/javascripts/turbo.js +67 -0
- data/lib/capybara/lightpanda/keyboard.rb +23 -19
- data/lib/capybara/lightpanda/network.rb +75 -13
- data/lib/capybara/lightpanda/node.rb +31 -31
- data/lib/capybara/lightpanda/options.rb +4 -0
- data/lib/capybara/lightpanda/process.rb +13 -22
- data/lib/capybara/lightpanda/railtie.rb +15 -0
- data/lib/capybara/lightpanda/version.rb +1 -1
- data/lib/capybara-lightpanda.rb +1 -0
- metadata +11 -2
- data/lib/capybara/lightpanda/javascripts/index.js +0 -239
|
@@ -3,25 +3,54 @@
|
|
|
3
3
|
module Capybara
|
|
4
4
|
module Lightpanda
|
|
5
5
|
class Network
|
|
6
|
+
# Tracking is always on (create_page enables it), so the buffer needs
|
|
7
|
+
# the same unbounded-growth cap as Browser's console ring buffer —
|
|
8
|
+
# one very long session would otherwise grow @traffic indefinitely.
|
|
9
|
+
TRAFFIC_LIMIT = 1_000
|
|
10
|
+
|
|
6
11
|
attr_reader :browser
|
|
7
12
|
|
|
13
|
+
# Status/headers of the last top-level document navigation; nil before
|
|
14
|
+
# the first navigation completes. Backs Browser#status_code /
|
|
15
|
+
# #response_headers. Captured by #subscribe's handlers.
|
|
16
|
+
attr_reader :last_navigation_response
|
|
17
|
+
|
|
8
18
|
def initialize(browser)
|
|
9
19
|
@browser = browser
|
|
10
20
|
@traffic = []
|
|
11
21
|
@traffic_mutex = Mutex.new
|
|
12
22
|
@enabled = false
|
|
13
|
-
@request_handler = nil
|
|
14
|
-
@
|
|
23
|
+
@request_handler = @response_handler = nil
|
|
24
|
+
@last_navigation_response = nil
|
|
25
|
+
@document_request_id = nil
|
|
15
26
|
end
|
|
16
27
|
|
|
28
|
+
# The domain toggle is connection-scoped (browser.command), while
|
|
29
|
+
# setExtraHTTPHeaders is session-scoped (browser.page_command) — see
|
|
30
|
+
# #headers=. Browser#create_page calls this, so tracking (traffic AND
|
|
31
|
+
# the navigation-response capture) is on for every page.
|
|
17
32
|
def enable
|
|
18
33
|
return if @enabled
|
|
19
34
|
|
|
20
|
-
|
|
35
|
+
# Subscribe BEFORE flipping the wire toggle (mirror image of
|
|
36
|
+
# #disable's ordering): events can't be emitted while the domain is
|
|
37
|
+
# off, so this order can never miss one. If the command fails
|
|
38
|
+
# (Lightpanda can block commands mid-navigation), roll the handlers
|
|
39
|
+
# back — orphaned duplicates would double-count every request and
|
|
40
|
+
# wedge pending_connections above zero for the session.
|
|
21
41
|
subscribe
|
|
42
|
+
begin
|
|
43
|
+
browser.command("Network.enable")
|
|
44
|
+
rescue StandardError
|
|
45
|
+
unsubscribe
|
|
46
|
+
raise
|
|
47
|
+
end
|
|
22
48
|
@enabled = true
|
|
23
49
|
end
|
|
24
50
|
|
|
51
|
+
# Caveat: disabling the domain also stops the navigation-response
|
|
52
|
+
# capture, so Browser#status_code / #response_headers freeze at their
|
|
53
|
+
# last values until the next #enable.
|
|
25
54
|
def disable
|
|
26
55
|
return unless @enabled
|
|
27
56
|
|
|
@@ -51,10 +80,16 @@ module Capybara
|
|
|
51
80
|
# cleared the Subscriber first.
|
|
52
81
|
def reset
|
|
53
82
|
unsubscribe
|
|
54
|
-
|
|
83
|
+
clear
|
|
55
84
|
@enabled = false
|
|
85
|
+
@extra_headers = nil # the fresh context never received setExtraHTTPHeaders
|
|
86
|
+
@last_navigation_response = nil
|
|
87
|
+
@document_request_id = nil
|
|
56
88
|
end
|
|
57
89
|
|
|
90
|
+
# Headers applied via headers= / add_headers. Backs Driver#headers.
|
|
91
|
+
def extra_headers = @extra_headers || {}
|
|
92
|
+
|
|
58
93
|
# Setting extra headers also lazily enables the Network domain. Without
|
|
59
94
|
# this, headers were silently ignored until the caller separately ran
|
|
60
95
|
# `network.enable` (or `wait_for_network_idle`). Cuprite/Ferrum parity.
|
|
@@ -108,11 +143,30 @@ module Capybara
|
|
|
108
143
|
|
|
109
144
|
private
|
|
110
145
|
|
|
146
|
+
# CDP events arrive on the message thread while wait_for_idle /
|
|
147
|
+
# pending_connections read from the main thread; serialize all
|
|
148
|
+
# @traffic mutations and reads through @traffic_mutex.
|
|
149
|
+
#
|
|
150
|
+
# The handlers also capture the last top-level navigation response
|
|
151
|
+
# (mirrors capybara-playwright-driver's navigation_request? hook). CDP
|
|
152
|
+
# normally marks the main-document response via
|
|
153
|
+
# `Network.responseReceived.type`, but Lightpanda omits that field on
|
|
154
|
+
# responses (only emits `type` on requestWillBeSent) — so match the
|
|
155
|
+
# long way: remember the document requestId, store the response whose
|
|
156
|
+
# requestId equals it.
|
|
111
157
|
def subscribe
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
158
|
+
@request_handler = build_request_handler
|
|
159
|
+
@response_handler = build_response_handler
|
|
160
|
+
browser.on("Network.requestWillBeSent", &@request_handler)
|
|
161
|
+
browser.on("Network.responseReceived", &@response_handler)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def build_request_handler
|
|
165
|
+
lambda do |params|
|
|
166
|
+
if params["type"] == "Document"
|
|
167
|
+
@document_request_id = params["requestId"]
|
|
168
|
+
@last_navigation_response = nil
|
|
169
|
+
end
|
|
116
170
|
entry = {
|
|
117
171
|
request_id: params["requestId"],
|
|
118
172
|
url: params.dig("request", "url"),
|
|
@@ -120,10 +174,21 @@ module Capybara
|
|
|
120
174
|
timestamp: params["timestamp"],
|
|
121
175
|
response: nil,
|
|
122
176
|
}
|
|
123
|
-
@traffic_mutex.synchronize
|
|
177
|
+
@traffic_mutex.synchronize do
|
|
178
|
+
@traffic << entry
|
|
179
|
+
@traffic.shift(@traffic.size - TRAFFIC_LIMIT) if @traffic.size > TRAFFIC_LIMIT
|
|
180
|
+
end
|
|
124
181
|
end
|
|
182
|
+
end
|
|
125
183
|
|
|
126
|
-
|
|
184
|
+
def build_response_handler
|
|
185
|
+
lambda do |params|
|
|
186
|
+
if params["requestId"] == @document_request_id
|
|
187
|
+
@last_navigation_response = {
|
|
188
|
+
status: params.dig("response", "status"),
|
|
189
|
+
headers: params.dig("response", "headers") || {},
|
|
190
|
+
}
|
|
191
|
+
end
|
|
127
192
|
@traffic_mutex.synchronize do
|
|
128
193
|
request = @traffic.find { |t| t[:request_id] == params["requestId"] }
|
|
129
194
|
next unless request
|
|
@@ -135,9 +200,6 @@ module Capybara
|
|
|
135
200
|
}
|
|
136
201
|
end
|
|
137
202
|
end
|
|
138
|
-
|
|
139
|
-
browser.on("Network.requestWillBeSent", &@request_handler)
|
|
140
|
-
browser.on("Network.responseReceived", &@response_handler)
|
|
141
203
|
end
|
|
142
204
|
|
|
143
205
|
def unsubscribe
|
|
@@ -69,14 +69,12 @@ module Capybara
|
|
|
69
69
|
previous
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
+
# Routed through #call (not a bare call_function_on) so a detached
|
|
73
|
+
# host raises ObsoleteNode like every other node operation — Capybara's
|
|
74
|
+
# automatic_reload then re-finds the host instead of silently reading
|
|
75
|
+
# a stale shadowRoot.
|
|
72
76
|
def shadow_root
|
|
73
|
-
result =
|
|
74
|
-
driver.browser.call_function_on(
|
|
75
|
-
@remote_object_id,
|
|
76
|
-
"function() { return this.shadowRoot }",
|
|
77
|
-
return_by_value: false
|
|
78
|
-
)
|
|
79
|
-
end
|
|
77
|
+
result = call(SHADOW_ROOT_JS, return_by_value: false)
|
|
80
78
|
return nil unless result.is_a?(Hash) && result["objectId"]
|
|
81
79
|
|
|
82
80
|
self.class.new(driver, result["objectId"])
|
|
@@ -171,15 +169,9 @@ module Capybara
|
|
|
171
169
|
end
|
|
172
170
|
|
|
173
171
|
def unselect_option
|
|
174
|
-
unless call(
|
|
175
|
-
var s = this.parentElement;
|
|
176
|
-
while (s && (s.tagName || '').toUpperCase() !== 'SELECT') s = s.parentElement;
|
|
177
|
-
return !!(s && s.multiple);
|
|
178
|
-
}")
|
|
179
|
-
raise Capybara::UnselectNotAllowed, "Cannot unselect option from single select box."
|
|
180
|
-
end
|
|
172
|
+
return unless call(UNSELECT_OPTION_JS) == "not_multiple"
|
|
181
173
|
|
|
182
|
-
|
|
174
|
+
raise Capybara::UnselectNotAllowed, "Cannot unselect option from single select box."
|
|
183
175
|
end
|
|
184
176
|
|
|
185
177
|
def send_keys(*)
|
|
@@ -437,22 +429,18 @@ module Capybara
|
|
|
437
429
|
# a DOM mutation like `replaceWith`, the cached objectId still resolves
|
|
438
430
|
# to the detached node, so reads succeed quietly and Capybara's
|
|
439
431
|
# automatic_reload never re-runs the original query.
|
|
440
|
-
def call(function_declaration, *args)
|
|
432
|
+
def call(function_declaration, *args, return_by_value: true)
|
|
441
433
|
guarded = wrap_with_attached_guard(function_declaration)
|
|
442
434
|
driver.browser.with_default_context_wait do
|
|
443
|
-
driver.browser.call_function_on(@remote_object_id, guarded, *args
|
|
435
|
+
driver.browser.call_function_on(@remote_object_id, guarded, *args,
|
|
436
|
+
return_by_value: return_by_value)
|
|
444
437
|
end
|
|
445
438
|
rescue JavaScriptError => e
|
|
446
439
|
if e.message.include?(OBSOLETE_NODE_MARKER)
|
|
447
440
|
raise ObsoleteNode.new(self, "Node is no longer attached to the document")
|
|
448
441
|
end
|
|
449
442
|
|
|
450
|
-
|
|
451
|
-
when "InvalidSelector"
|
|
452
|
-
raise InvalidSelector.new(e.message, nil, args.first)
|
|
453
|
-
else
|
|
454
|
-
raise
|
|
455
|
-
end
|
|
443
|
+
raise
|
|
456
444
|
end
|
|
457
445
|
|
|
458
446
|
OBSOLETE_NODE_MARKER = "LIGHTPANDA_OBSOLETE_NODE"
|
|
@@ -485,6 +473,13 @@ module Capybara
|
|
|
485
473
|
CLICK_JS = <<~JS
|
|
486
474
|
function() {
|
|
487
475
|
var EventCtor = (typeof MouseEvent !== 'undefined') ? MouseEvent : Event;
|
|
476
|
+
// Real pointer clicks are a mousedown -> mouseup -> click sequence,
|
|
477
|
+
// and widgets like select2 open on `mousedown`, not `click`.
|
|
478
|
+
// Cancelling mousedown suppresses focus/text-selection in a real
|
|
479
|
+
// browser but never the click, so the click below is dispatched
|
|
480
|
+
// unconditionally.
|
|
481
|
+
this.dispatchEvent(new EventCtor('mousedown', { bubbles: true, cancelable: true }));
|
|
482
|
+
this.dispatchEvent(new EventCtor('mouseup', { bubbles: true, cancelable: true }));
|
|
488
483
|
var clickEvt = new EventCtor('click', { bubbles: true, cancelable: true });
|
|
489
484
|
var notCancelled = this.dispatchEvent(clickEvt);
|
|
490
485
|
if (!notCancelled || clickEvt.defaultPrevented) return;
|
|
@@ -658,11 +653,14 @@ module Capybara
|
|
|
658
653
|
}
|
|
659
654
|
JS
|
|
660
655
|
|
|
656
|
+
# Returns 'not_multiple' (without mutating) when the owning <select>
|
|
657
|
+
# isn't multiple — Ruby raises Capybara::UnselectNotAllowed. One CDP
|
|
658
|
+
# round-trip instead of a separate ancestor-walk precheck.
|
|
661
659
|
UNSELECT_OPTION_JS = <<~JS
|
|
662
660
|
function() {
|
|
663
661
|
var sel = this.parentElement;
|
|
664
662
|
while (sel && (sel.tagName || '').toUpperCase() !== 'SELECT') sel = sel.parentElement;
|
|
665
|
-
if (!sel || !sel.multiple) return;
|
|
663
|
+
if (!sel || !sel.multiple) return 'not_multiple';
|
|
666
664
|
this.selected = false;
|
|
667
665
|
sel.dispatchEvent(new Event('input', {bubbles: true}));
|
|
668
666
|
sel.dispatchEvent(new Event('change', {bubbles: true}));
|
|
@@ -675,13 +673,7 @@ module Capybara
|
|
|
675
673
|
}
|
|
676
674
|
JS
|
|
677
675
|
|
|
678
|
-
|
|
679
|
-
function(key) {
|
|
680
|
-
this.focus();
|
|
681
|
-
this.value += key;
|
|
682
|
-
this.dispatchEvent(new Event('input', {bubbles: true}));
|
|
683
|
-
}
|
|
684
|
-
JS
|
|
676
|
+
SHADOW_ROOT_JS = "function() { return this.shadowRoot }"
|
|
685
677
|
|
|
686
678
|
EDITABLE_HOST_JS = "function() { return _lightpanda.isContentEditable(this); }"
|
|
687
679
|
|
|
@@ -731,6 +723,14 @@ module Capybara
|
|
|
731
723
|
return path.join(' > ');
|
|
732
724
|
}
|
|
733
725
|
JS
|
|
726
|
+
|
|
727
|
+
# Internal wire format, not API — aligned with browser.rb's convention
|
|
728
|
+
# of private_constant for its JS snippets.
|
|
729
|
+
private_constant :CLICK_JS, :TRIGGER_JS, :DROP_JS, :SHADOW_ROOT_JS, :VISIBLE_JS, :VISIBLE_TEXT_JS,
|
|
730
|
+
:PROPERTY_OR_ATTRIBUTE_JS, :GET_VALUE_JS, :SET_VALUE_JS,
|
|
731
|
+
:IMPLICIT_SUBMIT_JS, :SELECT_OPTION_JS, :UNSELECT_OPTION_JS,
|
|
732
|
+
:SET_CHECKBOX_JS, :EDITABLE_HOST_JS, :DISABLED_JS, :GET_STYLE_JS,
|
|
733
|
+
:GET_RECT_JS, :OBSCURED_JS, :GET_PATH_JS
|
|
734
734
|
end
|
|
735
735
|
end
|
|
736
736
|
end
|
|
@@ -22,6 +22,10 @@ module Capybara
|
|
|
22
22
|
DEFAULT_PORT = 0
|
|
23
23
|
DEFAULT_WINDOW_SIZE = [1024, 768].freeze
|
|
24
24
|
|
|
25
|
+
# window_size and headless are accepted for Cuprite drop-in
|
|
26
|
+
# compatibility (standard options at driver registration) but are
|
|
27
|
+
# inert: Lightpanda has no rendering engine, so there is nothing to
|
|
28
|
+
# resize, and headless is the only mode it runs in.
|
|
25
29
|
attr_accessor :host, :port, :timeout, :handshake_timeout, :process_timeout,
|
|
26
30
|
:window_size, :browser_path, :headless, :logger
|
|
27
31
|
attr_writer :ws_url
|
|
@@ -90,10 +90,16 @@ module Capybara
|
|
|
90
90
|
# DragEvent, merged 2026-06-10) provides the APIs Node#drop's DROP_JS
|
|
91
91
|
# assembles its payload from; on builds without it the drop JS raises
|
|
92
92
|
# "DataTransfer is not defined".
|
|
93
|
-
# Build 6699 = the #2671 merge (d1f4c409, 2026-06-10) —
|
|
94
|
-
# floor. (The prior 6672 file-upload floor — and 6353
|
|
95
|
-
# subsumed.)
|
|
96
|
-
|
|
93
|
+
# Build 6699 = the #2671 merge (d1f4c409, 2026-06-10) — the Node#drop
|
|
94
|
+
# DataTransfer floor. (The prior 6672 file-upload floor — and 6353
|
|
95
|
+
# before it — are subsumed.)
|
|
96
|
+
# PR #2708 (document fires readystatechange on readiness changes,
|
|
97
|
+
# merged 2026-06-12) made the index.js readystatechange re-dispatch
|
|
98
|
+
# shim redundant, so it was removed — on builds without #2708 Turbo's
|
|
99
|
+
# PageObserver never reaches pageLoaded() and turbo:load never fires.
|
|
100
|
+
# Build 6736 = first published nightly carrying the #2708 merge — now
|
|
101
|
+
# the binding floor.
|
|
102
|
+
MINIMUM_NIGHTLY_BUILD = Gem::Version.new("6736")
|
|
97
103
|
|
|
98
104
|
attr_reader :pid, :ws_url, :version, :nightly_build
|
|
99
105
|
|
|
@@ -117,9 +123,7 @@ module Capybara
|
|
|
117
123
|
|
|
118
124
|
check_minimum_version(binary_path)
|
|
119
125
|
attempt_start(binary_path)
|
|
120
|
-
rescue
|
|
121
|
-
raise unless e.message.include?("already in use")
|
|
122
|
-
|
|
126
|
+
rescue PortInUseError
|
|
123
127
|
kill_process_on_port(@options.port)
|
|
124
128
|
attempt_start(binary_path)
|
|
125
129
|
end
|
|
@@ -251,8 +255,8 @@ module Capybara
|
|
|
251
255
|
end
|
|
252
256
|
|
|
253
257
|
if output.match?(ADDRESS_IN_USE_PATTERN)
|
|
254
|
-
|
|
255
|
-
raise
|
|
258
|
+
stop
|
|
259
|
+
raise PortInUseError,
|
|
256
260
|
"Lightpanda failed to start: port #{@options.port} is already in use"
|
|
257
261
|
end
|
|
258
262
|
rescue IO::WaitReadable
|
|
@@ -269,19 +273,6 @@ module Capybara
|
|
|
269
273
|
end
|
|
270
274
|
end
|
|
271
275
|
|
|
272
|
-
def cleanup_failed_process
|
|
273
|
-
return unless @pid
|
|
274
|
-
|
|
275
|
-
begin
|
|
276
|
-
::Process.wait(@pid, ::Process::WNOHANG)
|
|
277
|
-
rescue Errno::ECHILD
|
|
278
|
-
nil
|
|
279
|
-
end
|
|
280
|
-
|
|
281
|
-
cleanup_pipes
|
|
282
|
-
@pid = nil
|
|
283
|
-
end
|
|
284
|
-
|
|
285
276
|
# Auto-recover when a previous Lightpanda is still bound to our port.
|
|
286
277
|
# Best-effort: relies on `lsof` to map port → pid (macOS / most Linux
|
|
287
278
|
# distros). Where `lsof` isn't on PATH, we surface a clear error rather
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capybara
|
|
4
|
+
module Lightpanda
|
|
5
|
+
# Exposes the gem's rake tasks (lightpanda:binary:*) inside Rails apps.
|
|
6
|
+
# Without this, the BinaryError remediation hint ("bundle exec rake
|
|
7
|
+
# lightpanda:binary:remove lightpanda:binary:update") pointed at tasks
|
|
8
|
+
# that didn't exist in the app's rake namespace.
|
|
9
|
+
class Railtie < Rails::Railtie
|
|
10
|
+
rake_tasks do
|
|
11
|
+
load File.expand_path("tasks/binary.rake", __dir__)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/capybara-lightpanda.rb
CHANGED
|
@@ -21,6 +21,7 @@ require_relative "capybara/lightpanda/auto_scripts"
|
|
|
21
21
|
require_relative "capybara/lightpanda/node"
|
|
22
22
|
require_relative "capybara/lightpanda/element_extension"
|
|
23
23
|
require_relative "capybara/lightpanda/driver"
|
|
24
|
+
require_relative "capybara/lightpanda/railtie" if defined?(Rails::Railtie)
|
|
24
25
|
|
|
25
26
|
module Capybara
|
|
26
27
|
module Lightpanda
|
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.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Navid Emad
|
|
@@ -74,6 +74,11 @@ files:
|
|
|
74
74
|
- lib/capybara/lightpanda/auto_scripts.rb
|
|
75
75
|
- lib/capybara/lightpanda/binary.rb
|
|
76
76
|
- lib/capybara/lightpanda/browser.rb
|
|
77
|
+
- lib/capybara/lightpanda/browser/console.rb
|
|
78
|
+
- lib/capybara/lightpanda/browser/finder.rb
|
|
79
|
+
- lib/capybara/lightpanda/browser/modals.rb
|
|
80
|
+
- lib/capybara/lightpanda/browser/navigation.rb
|
|
81
|
+
- lib/capybara/lightpanda/browser/runtime.rb
|
|
77
82
|
- lib/capybara/lightpanda/client.rb
|
|
78
83
|
- lib/capybara/lightpanda/client/subscriber.rb
|
|
79
84
|
- lib/capybara/lightpanda/client/web_socket.rb
|
|
@@ -82,13 +87,17 @@ files:
|
|
|
82
87
|
- lib/capybara/lightpanda/element_extension.rb
|
|
83
88
|
- lib/capybara/lightpanda/errors.rb
|
|
84
89
|
- lib/capybara/lightpanda/headers.rb
|
|
85
|
-
- lib/capybara/lightpanda/javascripts/
|
|
90
|
+
- lib/capybara/lightpanda/javascripts/attach.js
|
|
91
|
+
- lib/capybara/lightpanda/javascripts/banner.js
|
|
92
|
+
- lib/capybara/lightpanda/javascripts/predicates.js
|
|
93
|
+
- lib/capybara/lightpanda/javascripts/turbo.js
|
|
86
94
|
- lib/capybara/lightpanda/keyboard.rb
|
|
87
95
|
- lib/capybara/lightpanda/logger.rb
|
|
88
96
|
- lib/capybara/lightpanda/network.rb
|
|
89
97
|
- lib/capybara/lightpanda/node.rb
|
|
90
98
|
- lib/capybara/lightpanda/options.rb
|
|
91
99
|
- lib/capybara/lightpanda/process.rb
|
|
100
|
+
- lib/capybara/lightpanda/railtie.rb
|
|
92
101
|
- lib/capybara/lightpanda/tasks/binary.rake
|
|
93
102
|
- lib/capybara/lightpanda/utils/attempt.rb
|
|
94
103
|
- lib/capybara/lightpanda/utils/event.rb
|
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
(function() {
|
|
2
|
-
if (window._lightpanda) return;
|
|
3
|
-
|
|
4
|
-
// --- readystatechange re-dispatch (upstream gap, wishlist A36) ---
|
|
5
|
-
// Lightpanda transitions document.readyState correctly but never fires the
|
|
6
|
-
// readystatechange event. Turbo's PageObserver listens ONLY for that event
|
|
7
|
-
// to reach pageLoaded() — without it, turbo:load never fires. Re-dispatch
|
|
8
|
-
// from the two lifecycle events Lightpanda does fire: DOMContentLoaded
|
|
9
|
-
// (readyState=interactive) and window load (readyState=complete).
|
|
10
|
-
// Drop this block once the upstream fix is covered by MINIMUM_NIGHTLY_BUILD.
|
|
11
|
-
function _fireReadyStateChange() {
|
|
12
|
-
document.dispatchEvent(new Event('readystatechange'));
|
|
13
|
-
}
|
|
14
|
-
document.addEventListener('DOMContentLoaded', _fireReadyStateChange);
|
|
15
|
-
window.addEventListener('load', _fireReadyStateChange);
|
|
16
|
-
|
|
17
|
-
// --- Turbo activity tracking ---
|
|
18
|
-
// Tracks pending Turbo operations so the driver can wait for Turbo to settle.
|
|
19
|
-
// Inspired by the CapybaraLockstep approach for stabilizing Turbo integration tests.
|
|
20
|
-
// Events are not perfectly symmetrical in Turbo, so we track multiple pairs
|
|
21
|
-
// and use a counter to handle overlapping operations.
|
|
22
|
-
//
|
|
23
|
-
// Transitions across 0 emit `__lightpanda_turbo_busy` / `__lightpanda_turbo_idle`
|
|
24
|
-
// sentinels via console.debug. Browser#wait_for_turbo subscribes to those
|
|
25
|
-
// sentinels (Runtime.consoleAPICalled) and toggles a Concurrent::Event so the
|
|
26
|
-
// Ruby side can wait event-driven instead of polling.
|
|
27
|
-
//
|
|
28
|
-
// Pages without Turbo never trigger _turboStart, so no sentinels fire and the
|
|
29
|
-
// Ruby Event stays set (idle by default) — wait_for_turbo returns immediately.
|
|
30
|
-
var _pendingTurboOps = 0;
|
|
31
|
-
function _signalTurbo(state) {
|
|
32
|
-
try { console.debug('__lightpanda_turbo_' + state); } catch (e) {}
|
|
33
|
-
}
|
|
34
|
-
function _turboStart() {
|
|
35
|
-
_pendingTurboOps++;
|
|
36
|
-
if (_pendingTurboOps === 1) _signalTurbo('busy');
|
|
37
|
-
}
|
|
38
|
-
function _turboEnd() {
|
|
39
|
-
if (_pendingTurboOps > 0) {
|
|
40
|
-
_pendingTurboOps--;
|
|
41
|
-
if (_pendingTurboOps === 0) _signalTurbo('idle');
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Fetch requests (covers Drive, Frames, and Form submission fetches)
|
|
46
|
-
document.addEventListener('turbo:before-fetch-request', _turboStart);
|
|
47
|
-
document.addEventListener('turbo:before-fetch-response', _turboEnd);
|
|
48
|
-
document.addEventListener('turbo:fetch-request-error', _turboEnd);
|
|
49
|
-
|
|
50
|
-
// Form submissions (can outlast their underlying fetch)
|
|
51
|
-
document.addEventListener('turbo:submit-start', _turboStart);
|
|
52
|
-
document.addEventListener('turbo:submit-end', _turboEnd);
|
|
53
|
-
|
|
54
|
-
// Frame rendering (can outlast the fetch that triggered it)
|
|
55
|
-
document.addEventListener('turbo:before-frame-render', _turboStart);
|
|
56
|
-
document.addEventListener('turbo:frame-render', _turboEnd);
|
|
57
|
-
|
|
58
|
-
// Stream rendering (no symmetric end event — wrap the render function)
|
|
59
|
-
document.addEventListener('turbo:before-stream-render', function(event) {
|
|
60
|
-
_turboStart();
|
|
61
|
-
if (event.detail && event.detail.render) {
|
|
62
|
-
var originalRender = event.detail.render;
|
|
63
|
-
event.detail.render = function(streamElement) {
|
|
64
|
-
var result = originalRender(streamElement);
|
|
65
|
-
if (result && typeof result.then === 'function') {
|
|
66
|
-
return result.finally(_turboEnd);
|
|
67
|
-
}
|
|
68
|
-
_turboEnd();
|
|
69
|
-
return result;
|
|
70
|
-
};
|
|
71
|
-
} else {
|
|
72
|
-
_turboEnd();
|
|
73
|
-
}
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
// Drive page visits: turbo:load fires after the page is fully rendered.
|
|
77
|
-
// Also serves as a safety reset — clears any counter leaks from aborted fetches.
|
|
78
|
-
// Always re-signal idle so the Ruby Event re-arms even if some `_turboEnd`
|
|
79
|
-
// call dropped on the floor mid-navigation.
|
|
80
|
-
document.addEventListener('turbo:load', function() {
|
|
81
|
-
_pendingTurboOps = 0;
|
|
82
|
-
_signalTurbo('idle');
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
// --- Main API ---
|
|
86
|
-
|
|
87
|
-
window._lightpanda = {
|
|
88
|
-
turbo: {
|
|
89
|
-
pending: function() { return _pendingTurboOps; },
|
|
90
|
-
idle: function() { return _pendingTurboOps <= 0; }
|
|
91
|
-
},
|
|
92
|
-
|
|
93
|
-
// --- DOM visibility / state predicates ---
|
|
94
|
-
// Centralized so node.rb's predicate methods (visible?, obscured?, disabled?)
|
|
95
|
-
// and the visible_text walker share one implementation of the ancestor
|
|
96
|
-
// cascade. Available in iframes too because index.js is registered via
|
|
97
|
-
// Page.addScriptToEvaluateOnNewDocument.
|
|
98
|
-
|
|
99
|
-
// Returns true if `el` is visible per Capybara's semantics. Lightpanda's
|
|
100
|
-
// `checkVisibility()` walks ancestors and rejects `display:none` from
|
|
101
|
-
// inline styles, stylesheets, and the UA sheet (PR #2294 — covers
|
|
102
|
-
// HEAD/SCRIPT/STYLE/NOSCRIPT/TEMPLATE/TITLE, `[hidden]`, `[type=hidden]`,
|
|
103
|
-
// closed-<details> children). `visibility:hidden|collapse` is handled
|
|
104
|
-
// separately because checkVisibility's defaults (per spec) ignore it.
|
|
105
|
-
//
|
|
106
|
-
// Intentional upstream out-of-scope (upstream-wishlist.md C10):
|
|
107
|
-
// Lightpanda is a headless agentic browser and deliberately doesn't
|
|
108
|
-
// load external `<link rel=stylesheet>` or apply `@media` rules to
|
|
109
|
-
// the cascade, and `matchMedia()` returns false for every query.
|
|
110
|
-
// Responsive patterns that hide one of two mobile/desktop CTA
|
|
111
|
-
// duplicates via `@media (min-width: …) { display: none }` leak
|
|
112
|
-
// both variants past this check — Capybara then raises
|
|
113
|
-
// `Ambiguous: found 2 elements`. There is no in-gem fix: rebuilding
|
|
114
|
-
// the CSS cascade in JS would need a CSS parser, sync access to
|
|
115
|
-
// remote stylesheets, and a real media-query evaluator. Run
|
|
116
|
-
// cuprite for responsive-UI assertions.
|
|
117
|
-
isVisible: function(el) {
|
|
118
|
-
if (!el || el.nodeType !== 1) return false;
|
|
119
|
-
var win = el.ownerDocument.defaultView || window;
|
|
120
|
-
var style = win.getComputedStyle(el);
|
|
121
|
-
if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
|
|
122
|
-
return el.checkVisibility();
|
|
123
|
-
},
|
|
124
|
-
|
|
125
|
-
// Returns true if the element is obscured at its center point — i.e.
|
|
126
|
-
// hit-testing elementFromPoint at the center returns something that is
|
|
127
|
-
// not the element or its descendant. `visibility:hidden|collapse` is
|
|
128
|
-
// short-circuited explicitly because those elements still produce a
|
|
129
|
-
// layout box (so the rect-zero check below can't catch them); every
|
|
130
|
-
// other "not rendered" case (display:none, [hidden], descendants of
|
|
131
|
-
// either) falls out naturally because getBoundingClientRect returns
|
|
132
|
-
// DOMRect{0,0,0,0} for elements with no layout box.
|
|
133
|
-
isObscured: function(el) {
|
|
134
|
-
var doc = el.ownerDocument;
|
|
135
|
-
var win = doc.defaultView || window;
|
|
136
|
-
var style = win.getComputedStyle(el);
|
|
137
|
-
if (style.visibility === 'hidden' || style.visibility === 'collapse') return true;
|
|
138
|
-
var r = el.getBoundingClientRect();
|
|
139
|
-
if (r.width === 0 || r.height === 0) return true;
|
|
140
|
-
var cx = r.left + (r.width / 2);
|
|
141
|
-
var cy = r.top + (r.height / 2);
|
|
142
|
-
var w = win.innerWidth || doc.documentElement.clientWidth;
|
|
143
|
-
var h = win.innerHeight || doc.documentElement.clientHeight;
|
|
144
|
-
if (cx < 0 || cy < 0 || cx > w || cy > h) return true;
|
|
145
|
-
var hit = doc.elementFromPoint(cx, cy);
|
|
146
|
-
if (!hit) return true;
|
|
147
|
-
if (hit === el) return false;
|
|
148
|
-
return !el.contains(hit);
|
|
149
|
-
},
|
|
150
|
-
|
|
151
|
-
// Capybara's `Node#disabled?` is more permissive than CSS `:disabled`:
|
|
152
|
-
// an `<option>` inside a disabled `<select>` or a disabled `<fieldset>`
|
|
153
|
-
// is reported as disabled, even though those don't match CSS `:disabled`
|
|
154
|
-
// per the HTML spec. Mirrors Cuprite's behavior so the shared specs pass.
|
|
155
|
-
isDisabled: function(el) {
|
|
156
|
-
if (el.matches && el.matches(':disabled')) return true;
|
|
157
|
-
if ((el.tagName || '').toUpperCase() === 'OPTION') {
|
|
158
|
-
var p = el.parentElement;
|
|
159
|
-
while (p) {
|
|
160
|
-
if (p.disabled) return true;
|
|
161
|
-
p = p.parentElement;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
return false;
|
|
165
|
-
},
|
|
166
|
-
|
|
167
|
-
// True if the element is the host of a contenteditable region: it (or any
|
|
168
|
-
// ancestor) has a non-"false" `contenteditable` attribute. Lightpanda's
|
|
169
|
-
// native `HTMLElement.isContentEditable` (PR #2310) is hardwired to return
|
|
170
|
-
// `false` for every element — it has no caret/keyboard editing pipeline —
|
|
171
|
-
// so the IDL property is useless here and we walk ancestors ourselves.
|
|
172
|
-
isContentEditable: function(el) {
|
|
173
|
-
var n = el;
|
|
174
|
-
while (n && n.nodeType === 1) {
|
|
175
|
-
if (n.hasAttribute && n.hasAttribute('contenteditable')) {
|
|
176
|
-
var v = (n.getAttribute('contenteditable') || '').toLowerCase();
|
|
177
|
-
return v !== 'false';
|
|
178
|
-
}
|
|
179
|
-
n = n.parentElement;
|
|
180
|
-
}
|
|
181
|
-
return false;
|
|
182
|
-
},
|
|
183
|
-
|
|
184
|
-
// Walk descendants and accumulate text from visible nodes only. Inserts
|
|
185
|
-
// newlines around block-display containers so paragraphs/lists render with
|
|
186
|
-
// natural breaks (Capybara expects this from Chrome's innerText).
|
|
187
|
-
// Lightpanda's innerText returns textContent verbatim (no rendering, so no
|
|
188
|
-
// hidden-descendant filtering), so we DIY the visibility filtering here.
|
|
189
|
-
visibleText: function(el) {
|
|
190
|
-
var BLOCK_DISP = { BLOCK:1, FLEX:1, GRID:1, 'LIST-ITEM':1, TABLE:1, 'TABLE-ROW':1,
|
|
191
|
-
'TABLE-CAPTION':1, 'TABLE-CELL':1 };
|
|
192
|
-
var BLOCK_TAG = { ADDRESS:1, ARTICLE:1, ASIDE:1, BLOCKQUOTE:1, DETAILS:1, DIALOG:1,
|
|
193
|
-
DIV:1, DL:1, DT:1, DD:1, FIELDSET:1, FIGCAPTION:1, FIGURE:1,
|
|
194
|
-
FOOTER:1, FORM:1, H1:1, H2:1, H3:1, H4:1, H5:1, H6:1, HEADER:1,
|
|
195
|
-
HGROUP:1, HR:1, LI:1, MAIN:1, NAV:1, OL:1, P:1, PRE:1, SECTION:1,
|
|
196
|
-
TABLE:1, TR:1, UL:1 };
|
|
197
|
-
var self = this;
|
|
198
|
-
|
|
199
|
-
// Collapse runs of ASCII whitespace (preserving NBSP) to a single space —
|
|
200
|
-
// matches Chrome's innerText whitespace handling for text nodes.
|
|
201
|
-
function normText(s) {
|
|
202
|
-
return s.replace(/[\t\n\r\f\v ]+/g, ' ');
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function walk(node) {
|
|
206
|
-
if (node.nodeType === 3) return normText(node.nodeValue);
|
|
207
|
-
// DocumentFragment / ShadowRoot — no element of its own to test
|
|
208
|
-
// for visibility, just walk children.
|
|
209
|
-
if (node.nodeType === 11) {
|
|
210
|
-
var fout = '';
|
|
211
|
-
for (var k = 0; k < node.childNodes.length; k++) fout += walk(node.childNodes[k]);
|
|
212
|
-
return fout;
|
|
213
|
-
}
|
|
214
|
-
if (node.nodeType !== 1) return '';
|
|
215
|
-
if (!self.isVisible(node)) return '';
|
|
216
|
-
var tag = (node.tagName || '').toUpperCase();
|
|
217
|
-
if (tag === 'TEXTAREA') return node.value || '';
|
|
218
|
-
if (tag === 'BR') return '\n';
|
|
219
|
-
var win = node.ownerDocument.defaultView || window;
|
|
220
|
-
var style = win.getComputedStyle(node);
|
|
221
|
-
var disp = (style.display || '').toUpperCase();
|
|
222
|
-
var isBlock = BLOCK_DISP[disp] || BLOCK_TAG[tag];
|
|
223
|
-
var out = '';
|
|
224
|
-
for (var i = 0; i < node.childNodes.length; i++) {
|
|
225
|
-
out += walk(node.childNodes[i]);
|
|
226
|
-
}
|
|
227
|
-
// Block-level elements get wrapped in \n…\n only when they actually
|
|
228
|
-
// contribute visible text. An empty <div> between two inline siblings
|
|
229
|
-
// would otherwise introduce a phantom line break that Chrome's
|
|
230
|
-
// innerText algorithm collapses out (required line breaks around
|
|
231
|
-
// empty blocks coalesce in the line-collapse pass).
|
|
232
|
-
if (isBlock && /\S/.test(out)) out = '\n' + out + '\n';
|
|
233
|
-
return out;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
return walk(el);
|
|
237
|
-
}
|
|
238
|
-
};
|
|
239
|
-
})();
|