capybara-lightpanda 0.8.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 +25 -0
- data/lib/capybara/lightpanda/auto_scripts.rb +28 -2
- data/lib/capybara/lightpanda/binary.rb +2 -46
- 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 +61 -816
- 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/driver.rb +21 -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 +72 -13
- data/lib/capybara/lightpanda/node.rb +24 -31
- data/lib/capybara/lightpanda/options.rb +4 -0
- data/lib/capybara/lightpanda/process.rb +3 -18
- data/lib/capybara/lightpanda/version.rb +1 -1
- metadata +10 -2
- data/lib/capybara/lightpanda/javascripts/index.js +0 -226
|
@@ -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,8 +80,11 @@ 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
|
|
|
58
90
|
# Headers applied via headers= / add_headers. Backs Driver#headers.
|
|
@@ -111,11 +143,30 @@ module Capybara
|
|
|
111
143
|
|
|
112
144
|
private
|
|
113
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.
|
|
114
157
|
def subscribe
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
119
170
|
entry = {
|
|
120
171
|
request_id: params["requestId"],
|
|
121
172
|
url: params.dig("request", "url"),
|
|
@@ -123,10 +174,21 @@ module Capybara
|
|
|
123
174
|
timestamp: params["timestamp"],
|
|
124
175
|
response: nil,
|
|
125
176
|
}
|
|
126
|
-
@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
|
|
127
181
|
end
|
|
182
|
+
end
|
|
128
183
|
|
|
129
|
-
|
|
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
|
|
130
192
|
@traffic_mutex.synchronize do
|
|
131
193
|
request = @traffic.find { |t| t[:request_id] == params["requestId"] }
|
|
132
194
|
next unless request
|
|
@@ -138,9 +200,6 @@ module Capybara
|
|
|
138
200
|
}
|
|
139
201
|
end
|
|
140
202
|
end
|
|
141
|
-
|
|
142
|
-
browser.on("Network.requestWillBeSent", &@request_handler)
|
|
143
|
-
browser.on("Network.responseReceived", &@response_handler)
|
|
144
203
|
end
|
|
145
204
|
|
|
146
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"
|
|
@@ -665,11 +653,14 @@ module Capybara
|
|
|
665
653
|
}
|
|
666
654
|
JS
|
|
667
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.
|
|
668
659
|
UNSELECT_OPTION_JS = <<~JS
|
|
669
660
|
function() {
|
|
670
661
|
var sel = this.parentElement;
|
|
671
662
|
while (sel && (sel.tagName || '').toUpperCase() !== 'SELECT') sel = sel.parentElement;
|
|
672
|
-
if (!sel || !sel.multiple) return;
|
|
663
|
+
if (!sel || !sel.multiple) return 'not_multiple';
|
|
673
664
|
this.selected = false;
|
|
674
665
|
sel.dispatchEvent(new Event('input', {bubbles: true}));
|
|
675
666
|
sel.dispatchEvent(new Event('change', {bubbles: true}));
|
|
@@ -682,13 +673,7 @@ module Capybara
|
|
|
682
673
|
}
|
|
683
674
|
JS
|
|
684
675
|
|
|
685
|
-
|
|
686
|
-
function(key) {
|
|
687
|
-
this.focus();
|
|
688
|
-
this.value += key;
|
|
689
|
-
this.dispatchEvent(new Event('input', {bubbles: true}));
|
|
690
|
-
}
|
|
691
|
-
JS
|
|
676
|
+
SHADOW_ROOT_JS = "function() { return this.shadowRoot }"
|
|
692
677
|
|
|
693
678
|
EDITABLE_HOST_JS = "function() { return _lightpanda.isContentEditable(this); }"
|
|
694
679
|
|
|
@@ -738,6 +723,14 @@ module Capybara
|
|
|
738
723
|
return path.join(' > ');
|
|
739
724
|
}
|
|
740
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
|
|
741
734
|
end
|
|
742
735
|
end
|
|
743
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
|
|
@@ -123,9 +123,7 @@ module Capybara
|
|
|
123
123
|
|
|
124
124
|
check_minimum_version(binary_path)
|
|
125
125
|
attempt_start(binary_path)
|
|
126
|
-
rescue
|
|
127
|
-
raise unless e.message.include?("already in use")
|
|
128
|
-
|
|
126
|
+
rescue PortInUseError
|
|
129
127
|
kill_process_on_port(@options.port)
|
|
130
128
|
attempt_start(binary_path)
|
|
131
129
|
end
|
|
@@ -257,8 +255,8 @@ module Capybara
|
|
|
257
255
|
end
|
|
258
256
|
|
|
259
257
|
if output.match?(ADDRESS_IN_USE_PATTERN)
|
|
260
|
-
|
|
261
|
-
raise
|
|
258
|
+
stop
|
|
259
|
+
raise PortInUseError,
|
|
262
260
|
"Lightpanda failed to start: port #{@options.port} is already in use"
|
|
263
261
|
end
|
|
264
262
|
rescue IO::WaitReadable
|
|
@@ -275,19 +273,6 @@ module Capybara
|
|
|
275
273
|
end
|
|
276
274
|
end
|
|
277
275
|
|
|
278
|
-
def cleanup_failed_process
|
|
279
|
-
return unless @pid
|
|
280
|
-
|
|
281
|
-
begin
|
|
282
|
-
::Process.wait(@pid, ::Process::WNOHANG)
|
|
283
|
-
rescue Errno::ECHILD
|
|
284
|
-
nil
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
cleanup_pipes
|
|
288
|
-
@pid = nil
|
|
289
|
-
end
|
|
290
|
-
|
|
291
276
|
# Auto-recover when a previous Lightpanda is still bound to our port.
|
|
292
277
|
# Best-effort: relies on `lsof` to map port → pid (macOS / most Linux
|
|
293
278
|
# distros). Where `lsof` isn't on PATH, we surface a clear error rather
|
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,7 +87,10 @@ 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
|
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
(function() {
|
|
2
|
-
if (window._lightpanda) return;
|
|
3
|
-
|
|
4
|
-
// --- Turbo activity tracking ---
|
|
5
|
-
// Tracks pending Turbo operations so the driver can wait for Turbo to settle.
|
|
6
|
-
// Inspired by the CapybaraLockstep approach for stabilizing Turbo integration tests.
|
|
7
|
-
// Events are not perfectly symmetrical in Turbo, so we track multiple pairs
|
|
8
|
-
// and use a counter to handle overlapping operations.
|
|
9
|
-
//
|
|
10
|
-
// Transitions across 0 emit `__lightpanda_turbo_busy` / `__lightpanda_turbo_idle`
|
|
11
|
-
// sentinels via console.debug. Browser#wait_for_turbo subscribes to those
|
|
12
|
-
// sentinels (Runtime.consoleAPICalled) and toggles a Concurrent::Event so the
|
|
13
|
-
// Ruby side can wait event-driven instead of polling.
|
|
14
|
-
//
|
|
15
|
-
// Pages without Turbo never trigger _turboStart, so no sentinels fire and the
|
|
16
|
-
// Ruby Event stays set (idle by default) — wait_for_turbo returns immediately.
|
|
17
|
-
var _pendingTurboOps = 0;
|
|
18
|
-
function _signalTurbo(state) {
|
|
19
|
-
try { console.debug('__lightpanda_turbo_' + state); } catch (e) {}
|
|
20
|
-
}
|
|
21
|
-
function _turboStart() {
|
|
22
|
-
_pendingTurboOps++;
|
|
23
|
-
if (_pendingTurboOps === 1) _signalTurbo('busy');
|
|
24
|
-
}
|
|
25
|
-
function _turboEnd() {
|
|
26
|
-
if (_pendingTurboOps > 0) {
|
|
27
|
-
_pendingTurboOps--;
|
|
28
|
-
if (_pendingTurboOps === 0) _signalTurbo('idle');
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Fetch requests (covers Drive, Frames, and Form submission fetches)
|
|
33
|
-
document.addEventListener('turbo:before-fetch-request', _turboStart);
|
|
34
|
-
document.addEventListener('turbo:before-fetch-response', _turboEnd);
|
|
35
|
-
document.addEventListener('turbo:fetch-request-error', _turboEnd);
|
|
36
|
-
|
|
37
|
-
// Form submissions (can outlast their underlying fetch)
|
|
38
|
-
document.addEventListener('turbo:submit-start', _turboStart);
|
|
39
|
-
document.addEventListener('turbo:submit-end', _turboEnd);
|
|
40
|
-
|
|
41
|
-
// Frame rendering (can outlast the fetch that triggered it)
|
|
42
|
-
document.addEventListener('turbo:before-frame-render', _turboStart);
|
|
43
|
-
document.addEventListener('turbo:frame-render', _turboEnd);
|
|
44
|
-
|
|
45
|
-
// Stream rendering (no symmetric end event — wrap the render function)
|
|
46
|
-
document.addEventListener('turbo:before-stream-render', function(event) {
|
|
47
|
-
_turboStart();
|
|
48
|
-
if (event.detail && event.detail.render) {
|
|
49
|
-
var originalRender = event.detail.render;
|
|
50
|
-
event.detail.render = function(streamElement) {
|
|
51
|
-
var result = originalRender(streamElement);
|
|
52
|
-
if (result && typeof result.then === 'function') {
|
|
53
|
-
return result.finally(_turboEnd);
|
|
54
|
-
}
|
|
55
|
-
_turboEnd();
|
|
56
|
-
return result;
|
|
57
|
-
};
|
|
58
|
-
} else {
|
|
59
|
-
_turboEnd();
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
// Drive page visits: turbo:load fires after the page is fully rendered.
|
|
64
|
-
// Also serves as a safety reset — clears any counter leaks from aborted fetches.
|
|
65
|
-
// Always re-signal idle so the Ruby Event re-arms even if some `_turboEnd`
|
|
66
|
-
// call dropped on the floor mid-navigation.
|
|
67
|
-
document.addEventListener('turbo:load', function() {
|
|
68
|
-
_pendingTurboOps = 0;
|
|
69
|
-
_signalTurbo('idle');
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
// --- Main API ---
|
|
73
|
-
|
|
74
|
-
window._lightpanda = {
|
|
75
|
-
turbo: {
|
|
76
|
-
pending: function() { return _pendingTurboOps; },
|
|
77
|
-
idle: function() { return _pendingTurboOps <= 0; }
|
|
78
|
-
},
|
|
79
|
-
|
|
80
|
-
// --- DOM visibility / state predicates ---
|
|
81
|
-
// Centralized so node.rb's predicate methods (visible?, obscured?, disabled?)
|
|
82
|
-
// and the visible_text walker share one implementation of the ancestor
|
|
83
|
-
// cascade. Available in iframes too because index.js is registered via
|
|
84
|
-
// Page.addScriptToEvaluateOnNewDocument.
|
|
85
|
-
|
|
86
|
-
// Returns true if `el` is visible per Capybara's semantics. Lightpanda's
|
|
87
|
-
// `checkVisibility()` walks ancestors and rejects `display:none` from
|
|
88
|
-
// inline styles, stylesheets, and the UA sheet (PR #2294 — covers
|
|
89
|
-
// HEAD/SCRIPT/STYLE/NOSCRIPT/TEMPLATE/TITLE, `[hidden]`, `[type=hidden]`,
|
|
90
|
-
// closed-<details> children). `visibility:hidden|collapse` is handled
|
|
91
|
-
// separately because checkVisibility's defaults (per spec) ignore it.
|
|
92
|
-
//
|
|
93
|
-
// Intentional upstream out-of-scope (upstream-wishlist.md C10):
|
|
94
|
-
// Lightpanda is a headless agentic browser and deliberately doesn't
|
|
95
|
-
// load external `<link rel=stylesheet>` or apply `@media` rules to
|
|
96
|
-
// the cascade, and `matchMedia()` returns false for every query.
|
|
97
|
-
// Responsive patterns that hide one of two mobile/desktop CTA
|
|
98
|
-
// duplicates via `@media (min-width: …) { display: none }` leak
|
|
99
|
-
// both variants past this check — Capybara then raises
|
|
100
|
-
// `Ambiguous: found 2 elements`. There is no in-gem fix: rebuilding
|
|
101
|
-
// the CSS cascade in JS would need a CSS parser, sync access to
|
|
102
|
-
// remote stylesheets, and a real media-query evaluator. Run
|
|
103
|
-
// cuprite for responsive-UI assertions.
|
|
104
|
-
isVisible: function(el) {
|
|
105
|
-
if (!el || el.nodeType !== 1) return false;
|
|
106
|
-
var win = el.ownerDocument.defaultView || window;
|
|
107
|
-
var style = win.getComputedStyle(el);
|
|
108
|
-
if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
|
|
109
|
-
return el.checkVisibility();
|
|
110
|
-
},
|
|
111
|
-
|
|
112
|
-
// Returns true if the element is obscured at its center point — i.e.
|
|
113
|
-
// hit-testing elementFromPoint at the center returns something that is
|
|
114
|
-
// not the element or its descendant. `visibility:hidden|collapse` is
|
|
115
|
-
// short-circuited explicitly because those elements still produce a
|
|
116
|
-
// layout box (so the rect-zero check below can't catch them); every
|
|
117
|
-
// other "not rendered" case (display:none, [hidden], descendants of
|
|
118
|
-
// either) falls out naturally because getBoundingClientRect returns
|
|
119
|
-
// DOMRect{0,0,0,0} for elements with no layout box.
|
|
120
|
-
isObscured: function(el) {
|
|
121
|
-
var doc = el.ownerDocument;
|
|
122
|
-
var win = doc.defaultView || window;
|
|
123
|
-
var style = win.getComputedStyle(el);
|
|
124
|
-
if (style.visibility === 'hidden' || style.visibility === 'collapse') return true;
|
|
125
|
-
var r = el.getBoundingClientRect();
|
|
126
|
-
if (r.width === 0 || r.height === 0) return true;
|
|
127
|
-
var cx = r.left + (r.width / 2);
|
|
128
|
-
var cy = r.top + (r.height / 2);
|
|
129
|
-
var w = win.innerWidth || doc.documentElement.clientWidth;
|
|
130
|
-
var h = win.innerHeight || doc.documentElement.clientHeight;
|
|
131
|
-
if (cx < 0 || cy < 0 || cx > w || cy > h) return true;
|
|
132
|
-
var hit = doc.elementFromPoint(cx, cy);
|
|
133
|
-
if (!hit) return true;
|
|
134
|
-
if (hit === el) return false;
|
|
135
|
-
return !el.contains(hit);
|
|
136
|
-
},
|
|
137
|
-
|
|
138
|
-
// Capybara's `Node#disabled?` is more permissive than CSS `:disabled`:
|
|
139
|
-
// an `<option>` inside a disabled `<select>` or a disabled `<fieldset>`
|
|
140
|
-
// is reported as disabled, even though those don't match CSS `:disabled`
|
|
141
|
-
// per the HTML spec. Mirrors Cuprite's behavior so the shared specs pass.
|
|
142
|
-
isDisabled: function(el) {
|
|
143
|
-
if (el.matches && el.matches(':disabled')) return true;
|
|
144
|
-
if ((el.tagName || '').toUpperCase() === 'OPTION') {
|
|
145
|
-
var p = el.parentElement;
|
|
146
|
-
while (p) {
|
|
147
|
-
if (p.disabled) return true;
|
|
148
|
-
p = p.parentElement;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
return false;
|
|
152
|
-
},
|
|
153
|
-
|
|
154
|
-
// True if the element is the host of a contenteditable region: it (or any
|
|
155
|
-
// ancestor) has a non-"false" `contenteditable` attribute. Lightpanda's
|
|
156
|
-
// native `HTMLElement.isContentEditable` (PR #2310) is hardwired to return
|
|
157
|
-
// `false` for every element — it has no caret/keyboard editing pipeline —
|
|
158
|
-
// so the IDL property is useless here and we walk ancestors ourselves.
|
|
159
|
-
isContentEditable: function(el) {
|
|
160
|
-
var n = el;
|
|
161
|
-
while (n && n.nodeType === 1) {
|
|
162
|
-
if (n.hasAttribute && n.hasAttribute('contenteditable')) {
|
|
163
|
-
var v = (n.getAttribute('contenteditable') || '').toLowerCase();
|
|
164
|
-
return v !== 'false';
|
|
165
|
-
}
|
|
166
|
-
n = n.parentElement;
|
|
167
|
-
}
|
|
168
|
-
return false;
|
|
169
|
-
},
|
|
170
|
-
|
|
171
|
-
// Walk descendants and accumulate text from visible nodes only. Inserts
|
|
172
|
-
// newlines around block-display containers so paragraphs/lists render with
|
|
173
|
-
// natural breaks (Capybara expects this from Chrome's innerText).
|
|
174
|
-
// Lightpanda's innerText returns textContent verbatim (no rendering, so no
|
|
175
|
-
// hidden-descendant filtering), so we DIY the visibility filtering here.
|
|
176
|
-
visibleText: function(el) {
|
|
177
|
-
var BLOCK_DISP = { BLOCK:1, FLEX:1, GRID:1, 'LIST-ITEM':1, TABLE:1, 'TABLE-ROW':1,
|
|
178
|
-
'TABLE-CAPTION':1, 'TABLE-CELL':1 };
|
|
179
|
-
var BLOCK_TAG = { ADDRESS:1, ARTICLE:1, ASIDE:1, BLOCKQUOTE:1, DETAILS:1, DIALOG:1,
|
|
180
|
-
DIV:1, DL:1, DT:1, DD:1, FIELDSET:1, FIGCAPTION:1, FIGURE:1,
|
|
181
|
-
FOOTER:1, FORM:1, H1:1, H2:1, H3:1, H4:1, H5:1, H6:1, HEADER:1,
|
|
182
|
-
HGROUP:1, HR:1, LI:1, MAIN:1, NAV:1, OL:1, P:1, PRE:1, SECTION:1,
|
|
183
|
-
TABLE:1, TR:1, UL:1 };
|
|
184
|
-
var self = this;
|
|
185
|
-
|
|
186
|
-
// Collapse runs of ASCII whitespace (preserving NBSP) to a single space —
|
|
187
|
-
// matches Chrome's innerText whitespace handling for text nodes.
|
|
188
|
-
function normText(s) {
|
|
189
|
-
return s.replace(/[\t\n\r\f\v ]+/g, ' ');
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function walk(node) {
|
|
193
|
-
if (node.nodeType === 3) return normText(node.nodeValue);
|
|
194
|
-
// DocumentFragment / ShadowRoot — no element of its own to test
|
|
195
|
-
// for visibility, just walk children.
|
|
196
|
-
if (node.nodeType === 11) {
|
|
197
|
-
var fout = '';
|
|
198
|
-
for (var k = 0; k < node.childNodes.length; k++) fout += walk(node.childNodes[k]);
|
|
199
|
-
return fout;
|
|
200
|
-
}
|
|
201
|
-
if (node.nodeType !== 1) return '';
|
|
202
|
-
if (!self.isVisible(node)) return '';
|
|
203
|
-
var tag = (node.tagName || '').toUpperCase();
|
|
204
|
-
if (tag === 'TEXTAREA') return node.value || '';
|
|
205
|
-
if (tag === 'BR') return '\n';
|
|
206
|
-
var win = node.ownerDocument.defaultView || window;
|
|
207
|
-
var style = win.getComputedStyle(node);
|
|
208
|
-
var disp = (style.display || '').toUpperCase();
|
|
209
|
-
var isBlock = BLOCK_DISP[disp] || BLOCK_TAG[tag];
|
|
210
|
-
var out = '';
|
|
211
|
-
for (var i = 0; i < node.childNodes.length; i++) {
|
|
212
|
-
out += walk(node.childNodes[i]);
|
|
213
|
-
}
|
|
214
|
-
// Block-level elements get wrapped in \n…\n only when they actually
|
|
215
|
-
// contribute visible text. An empty <div> between two inline siblings
|
|
216
|
-
// would otherwise introduce a phantom line break that Chrome's
|
|
217
|
-
// innerText algorithm collapses out (required line breaks around
|
|
218
|
-
// empty blocks coalesce in the line-collapse pass).
|
|
219
|
-
if (isBlock && /\S/.test(out)) out = '\n' + out + '\n';
|
|
220
|
-
return out;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
return walk(el);
|
|
224
|
-
}
|
|
225
|
-
};
|
|
226
|
-
})();
|