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
|
@@ -4,6 +4,8 @@ require "json"
|
|
|
4
4
|
require "socket"
|
|
5
5
|
require "websocket/driver"
|
|
6
6
|
|
|
7
|
+
require_relative "../utils/attempt"
|
|
8
|
+
|
|
7
9
|
module Capybara
|
|
8
10
|
module Lightpanda
|
|
9
11
|
class Client
|
|
@@ -36,7 +38,7 @@ module Capybara
|
|
|
36
38
|
|
|
37
39
|
@status = :closing
|
|
38
40
|
@messages.close
|
|
39
|
-
@driver&.close
|
|
41
|
+
@driver_mutex.synchronize { @driver&.close }
|
|
40
42
|
@thread&.join(1) || @thread&.kill
|
|
41
43
|
@socket&.close
|
|
42
44
|
@status = :closed
|
|
@@ -46,15 +48,10 @@ module Capybara
|
|
|
46
48
|
@status == :closed || @status == :error
|
|
47
49
|
end
|
|
48
50
|
|
|
49
|
-
def open?
|
|
50
|
-
@status == :open
|
|
51
|
-
end
|
|
52
|
-
|
|
53
51
|
def write(data)
|
|
54
52
|
@socket.write(data)
|
|
55
53
|
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
56
|
-
|
|
57
|
-
@messages.close
|
|
54
|
+
mark_dead
|
|
58
55
|
end
|
|
59
56
|
|
|
60
57
|
private
|
|
@@ -73,13 +70,9 @@ module Capybara
|
|
|
73
70
|
start_reader_thread
|
|
74
71
|
end
|
|
75
72
|
|
|
76
|
-
def connect_with_retry(host, port
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
rescue Errno::ECONNREFUSED
|
|
80
|
-
raise if i == retries - 1
|
|
81
|
-
|
|
82
|
-
sleep delay
|
|
73
|
+
def connect_with_retry(host, port)
|
|
74
|
+
Utils::Attempt.with_retry(errors: Errno::ECONNREFUSED, max: 10, wait: 0.1) do
|
|
75
|
+
TCPSocket.new(host, port)
|
|
83
76
|
end
|
|
84
77
|
end
|
|
85
78
|
|
|
@@ -97,8 +90,7 @@ module Capybara
|
|
|
97
90
|
end
|
|
98
91
|
|
|
99
92
|
@driver.on(:close) do
|
|
100
|
-
|
|
101
|
-
@messages.close
|
|
93
|
+
mark_dead
|
|
102
94
|
end
|
|
103
95
|
|
|
104
96
|
@driver.on(:error) do |event|
|
|
@@ -109,8 +101,7 @@ module Capybara
|
|
|
109
101
|
# process. Mark the connection dead and let Client#command
|
|
110
102
|
# surface DeadBrowserError on its next dispatch via closed?.
|
|
111
103
|
@logger&.puts("✗ WebSocket error: #{event.message}")
|
|
112
|
-
|
|
113
|
-
@messages.close
|
|
104
|
+
mark_dead(:error)
|
|
114
105
|
end
|
|
115
106
|
end
|
|
116
107
|
|
|
@@ -127,8 +118,7 @@ module Capybara
|
|
|
127
118
|
data = @socket.readpartial(4096)
|
|
128
119
|
@driver_mutex.synchronize { @driver.parse(data) }
|
|
129
120
|
rescue Errno::ECONNRESET, Errno::EPIPE, IOError
|
|
130
|
-
|
|
131
|
-
@messages.close
|
|
121
|
+
mark_dead
|
|
132
122
|
break
|
|
133
123
|
end
|
|
134
124
|
end
|
|
@@ -144,7 +134,7 @@ module Capybara
|
|
|
144
134
|
begin
|
|
145
135
|
data = @socket.readpartial(4096)
|
|
146
136
|
@driver.parse(data)
|
|
147
|
-
rescue EOFError
|
|
137
|
+
rescue Errno::ECONNRESET, Errno::EPIPE, IOError # IOError covers EOFError
|
|
148
138
|
raise DeadBrowserError, "Connection closed during handshake"
|
|
149
139
|
end
|
|
150
140
|
end
|
|
@@ -154,6 +144,14 @@ module Capybara
|
|
|
154
144
|
raise TimeoutError, "WebSocket handshake timed out after #{@options.handshake_timeout}s"
|
|
155
145
|
end
|
|
156
146
|
|
|
147
|
+
# Single home for the "dead implies queue closed" invariant: every
|
|
148
|
+
# path that gives up on the connection must close @messages so the
|
|
149
|
+
# Client message thread's blocking pop returns.
|
|
150
|
+
def mark_dead(status = :closed)
|
|
151
|
+
@status = status
|
|
152
|
+
@messages.close
|
|
153
|
+
end
|
|
154
|
+
|
|
157
155
|
def parse_message(data)
|
|
158
156
|
JSON.parse(data, max_nesting: false)
|
|
159
157
|
rescue JSON::ParserError => e
|
|
@@ -9,10 +9,7 @@ require_relative "client/subscriber"
|
|
|
9
9
|
module Capybara
|
|
10
10
|
module Lightpanda
|
|
11
11
|
class Client
|
|
12
|
-
attr_reader :ws_url, :options
|
|
13
|
-
|
|
14
12
|
def initialize(ws_url, options)
|
|
15
|
-
@ws_url = ws_url
|
|
16
13
|
@options = options
|
|
17
14
|
@ws = WebSocket.new(ws_url, options)
|
|
18
15
|
@command_id = 0
|
|
@@ -118,7 +115,11 @@ module Capybara
|
|
|
118
115
|
def handle_message(message)
|
|
119
116
|
if message["id"]
|
|
120
117
|
pending = @pendings[message["id"]]
|
|
121
|
-
|
|
118
|
+
# try_set, not set: a duplicate frame for an already-answered id
|
|
119
|
+
# (Lightpanda emits occasional malformed/duplicate frames — see
|
|
120
|
+
# upstream-wishlist.md A41) would raise MultipleAssignmentError on
|
|
121
|
+
# this thread, and abort_on_exception would kill the whole process.
|
|
122
|
+
pending&.try_set(message)
|
|
122
123
|
elsif message["method"]
|
|
123
124
|
@subscriber.dispatch(message["method"], message["params"])
|
|
124
125
|
end
|
|
@@ -10,7 +10,7 @@ module Capybara
|
|
|
10
10
|
|
|
11
11
|
attr_reader :app, :options
|
|
12
12
|
|
|
13
|
-
delegate %i[current_url title status_code response_headers] => :browser
|
|
13
|
+
delegate %i[current_url title status_code response_headers frame_url frame_title] => :browser
|
|
14
14
|
|
|
15
15
|
def initialize(app, options = {})
|
|
16
16
|
super()
|
|
@@ -26,9 +26,7 @@ module Capybara
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def browser_alive?
|
|
29
|
-
|
|
30
|
-
rescue StandardError
|
|
31
|
-
false
|
|
29
|
+
!@browser.nil? && @browser.alive?
|
|
32
30
|
end
|
|
33
31
|
|
|
34
32
|
# Escape hatch to the underlying Browser for callers that need raw CDP
|
|
@@ -160,42 +158,20 @@ module Capybara
|
|
|
160
158
|
end
|
|
161
159
|
end
|
|
162
160
|
|
|
163
|
-
# Capybara::Driver::Base falls back to running these via the top
|
|
164
|
-
# execution context, which always reports the parent document. Resolve
|
|
165
|
-
# them through the iframe element's contentWindow / contentDocument so
|
|
166
|
-
# they reflect the active frame.
|
|
167
|
-
def frame_url
|
|
168
|
-
frame = browser.frame_stack.last
|
|
169
|
-
return browser.current_url unless frame
|
|
170
|
-
|
|
171
|
-
browser.call_function_on(frame.remote_object_id,
|
|
172
|
-
"function() { return this.contentWindow.location.href }")
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
def frame_title
|
|
176
|
-
frame = browser.frame_stack.last
|
|
177
|
-
return browser.title unless frame
|
|
178
|
-
|
|
179
|
-
browser.call_function_on(frame.remote_object_id,
|
|
180
|
-
"function() { return this.contentDocument.title }")
|
|
181
|
-
end
|
|
182
|
-
|
|
183
161
|
# -- Modal/Dialog Support --
|
|
184
162
|
|
|
163
|
+
# find_modal owns the wait default (browser.options.timeout) — pass
|
|
164
|
+
# wait only when the caller overrode it.
|
|
185
165
|
def accept_modal(type, **options, &block)
|
|
186
166
|
browser.accept_modal(type, text: options[:with])
|
|
187
167
|
block&.call
|
|
188
|
-
browser.find_modal(type,
|
|
189
|
-
text: options[:text],
|
|
190
|
-
wait: options.fetch(:wait, browser.options.timeout))
|
|
168
|
+
browser.find_modal(type, **{ text: options[:text], wait: options[:wait] }.compact)
|
|
191
169
|
end
|
|
192
170
|
|
|
193
171
|
def dismiss_modal(type, **options, &block)
|
|
194
172
|
browser.dismiss_modal(type)
|
|
195
173
|
block&.call
|
|
196
|
-
browser.find_modal(type,
|
|
197
|
-
text: options[:text],
|
|
198
|
-
wait: options.fetch(:wait, browser.options.timeout))
|
|
174
|
+
browser.find_modal(type, **{ text: options[:text], wait: options[:wait] }.compact)
|
|
199
175
|
end
|
|
200
176
|
|
|
201
177
|
# -- Screenshots --
|
|
@@ -241,12 +217,20 @@ module Capybara
|
|
|
241
217
|
# Thin Cuprite-style wrapper. The interesting work — disposing the
|
|
242
218
|
# BrowserContext (cookies, storage, all targets) and starting a fresh
|
|
243
219
|
# one — happens in Browser#reset.
|
|
220
|
+
#
|
|
221
|
+
# Rescue is the gem hierarchy plus raw IO escapees only — NOT
|
|
222
|
+
# StandardError: reset! runs between every test, so a blanket rescue
|
|
223
|
+
# turns programmer errors (e.g. a NoMethodError in browser.rb) into a
|
|
224
|
+
# silent quit-and-respawn on every example with zero signal. The warn
|
|
225
|
+
# keeps repeated respawns visible.
|
|
244
226
|
def reset!
|
|
245
227
|
browser.reset
|
|
246
|
-
|
|
247
|
-
|
|
228
|
+
rescue Error, SystemCallError, IOError => e
|
|
229
|
+
warn "[capybara-lightpanda] reset! failed (#{e.class}: #{e.message}); respawning browser"
|
|
248
230
|
@browser&.quit
|
|
249
231
|
@browser = nil
|
|
232
|
+
ensure
|
|
233
|
+
@started = false
|
|
250
234
|
end
|
|
251
235
|
|
|
252
236
|
def quit
|
|
@@ -315,14 +299,15 @@ module Capybara
|
|
|
315
299
|
end
|
|
316
300
|
|
|
317
301
|
# Walk through evaluate-script results turning DOM-node markers (the
|
|
318
|
-
# `{
|
|
319
|
-
# into Lightpanda::Node instances so
|
|
302
|
+
# `{ Browser::NODE_MARKER => "..." }` hashes produced by
|
|
303
|
+
# `Browser#unwrap_call_result`) into Lightpanda::Node instances so
|
|
304
|
+
# Capybara can wrap them as elements.
|
|
320
305
|
def unwrap_script_result(value)
|
|
321
306
|
case value
|
|
322
307
|
when Array then value.map { |v| unwrap_script_result(v) }
|
|
323
308
|
when Hash
|
|
324
|
-
if value.size == 1 && value.key?(
|
|
325
|
-
Node.new(self, value[
|
|
309
|
+
if value.size == 1 && value.key?(Browser::NODE_MARKER)
|
|
310
|
+
Node.new(self, value[Browser::NODE_MARKER])
|
|
326
311
|
else
|
|
327
312
|
value.transform_values { |v| unwrap_script_result(v) }
|
|
328
313
|
end
|
|
@@ -5,6 +5,9 @@ module Capybara
|
|
|
5
5
|
class Error < StandardError; end
|
|
6
6
|
|
|
7
7
|
class ProcessTimeoutError < Error; end
|
|
8
|
+
# Subclass so external `rescue ProcessTimeoutError` keeps catching it;
|
|
9
|
+
# Process#start dispatches on the class instead of message matching.
|
|
10
|
+
class PortInUseError < ProcessTimeoutError; end
|
|
8
11
|
class BinaryNotFoundError < Error; end
|
|
9
12
|
class BinaryError < Error; end
|
|
10
13
|
class UnsupportedPlatformError < Error; end
|
|
@@ -88,6 +91,22 @@ module Capybara
|
|
|
88
91
|
end
|
|
89
92
|
end
|
|
90
93
|
|
|
94
|
+
class InvalidSelector < Error
|
|
95
|
+
attr_reader :method, :selector
|
|
96
|
+
|
|
97
|
+
def initialize(message, method = nil, selector = nil)
|
|
98
|
+
@method = method
|
|
99
|
+
@selector = selector
|
|
100
|
+
super(message)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# --- Cuprite/Ferrum drop-in compatibility surface ---
|
|
105
|
+
# This gem never raises the three classes below (no coordinate-based
|
|
106
|
+
# mouse path, no multi-page API, no page-status tracking). They mirror
|
|
107
|
+
# the peer taxonomy (Cuprite's MouseEventFailed, Ferrum's
|
|
108
|
+
# NoSuchPageError/StatusError) so suites migrating from those drivers
|
|
109
|
+
# don't NameError on rescue lists that reference them.
|
|
91
110
|
class MouseEventFailed < BrowserError
|
|
92
111
|
attr_reader :node, :selector, :position
|
|
93
112
|
|
|
@@ -103,16 +122,6 @@ module Capybara
|
|
|
103
122
|
end
|
|
104
123
|
end
|
|
105
124
|
|
|
106
|
-
class InvalidSelector < Error
|
|
107
|
-
attr_reader :method, :selector
|
|
108
|
-
|
|
109
|
-
def initialize(message, method = nil, selector = nil)
|
|
110
|
-
@method = method
|
|
111
|
-
@selector = selector
|
|
112
|
-
super(message)
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
|
|
116
125
|
class NoSuchPageError < Error; end
|
|
117
126
|
class StatusError < Error; end
|
|
118
127
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// --- Public surface ---
|
|
2
|
+
// The one place that names window._lightpanda. References the function/var
|
|
3
|
+
// declarations from turbo.js and predicates.js (hoisted into the same IIFE
|
|
4
|
+
// scope by AutoScripts). Runs last in the concatenation order.
|
|
5
|
+
window._lightpanda = {
|
|
6
|
+
turbo: {
|
|
7
|
+
pending: function() { return _pendingTurboOps; },
|
|
8
|
+
idle: function() { return _pendingTurboOps <= 0; }
|
|
9
|
+
},
|
|
10
|
+
|
|
11
|
+
isVisible: isVisible,
|
|
12
|
+
isObscured: isObscured,
|
|
13
|
+
isDisabled: isDisabled,
|
|
14
|
+
isContentEditable: isContentEditable,
|
|
15
|
+
visibleText: visibleText
|
|
16
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// _lightpanda auto-injected bundle. Assembled by AutoScripts (auto_scripts.rb)
|
|
2
|
+
// from the sibling source files in this directory and registered once per
|
|
3
|
+
// session via Page.addScriptToEvaluateOnNewDocument, so it runs in every
|
|
4
|
+
// document — top frame and every iframe.
|
|
5
|
+
//
|
|
6
|
+
// Each source file is a plain sequence of declarations/statements: no IIFE,
|
|
7
|
+
// no `export`/`import`/`require`. AutoScripts wraps them in the IIFE and the
|
|
8
|
+
// `window._lightpanda` idempotency guard. Keeping the files free of module
|
|
9
|
+
// syntax means they parse identically as a classic browser script and as a
|
|
10
|
+
// `new Function(...)` body — which is exactly how the Bun harness (test/js/)
|
|
11
|
+
// loads predicates.js to test them without a build step.
|
|
12
|
+
//
|
|
13
|
+
// Concatenation order (set in auto_scripts.rb): banner, turbo, predicates,
|
|
14
|
+
// attach. `attach.js` reads the names defined by `turbo.js`/`predicates.js`,
|
|
15
|
+
// so it must come last.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// --- DOM visibility / state predicates ---
|
|
2
|
+
// Centralized so node.rb's predicate methods (visible?, obscured?, disabled?)
|
|
3
|
+
// and the visible_text walker share one implementation of the ancestor
|
|
4
|
+
// cascade. Available in iframes too because the bundle is registered via
|
|
5
|
+
// Page.addScriptToEvaluateOnNewDocument.
|
|
6
|
+
//
|
|
7
|
+
// These are bare top-level function declarations — attach.js exposes them on
|
|
8
|
+
// window._lightpanda. They reference each other by name (visibleText calls
|
|
9
|
+
// isVisible), not through `this`, so they keep working when node.rb invokes
|
|
10
|
+
// `_lightpanda.visibleText(this)` with `this` bound to a DOM element by CDP.
|
|
11
|
+
|
|
12
|
+
// Returns true if `el` is visible per Capybara's semantics. Lightpanda's
|
|
13
|
+
// `checkVisibility()` walks ancestors and rejects `display:none` from
|
|
14
|
+
// inline styles, stylesheets, and the UA sheet (PR #2294 — covers
|
|
15
|
+
// HEAD/SCRIPT/STYLE/NOSCRIPT/TEMPLATE/TITLE, `[hidden]`, `[type=hidden]`,
|
|
16
|
+
// closed-<details> children). `visibility:hidden|collapse` is handled
|
|
17
|
+
// separately because checkVisibility's defaults (per spec) ignore it.
|
|
18
|
+
//
|
|
19
|
+
// External `<link rel=stylesheet>` ARE fetched and applied — the gem
|
|
20
|
+
// always passes --enable-external-stylesheets (Lightpanda PR #2487,
|
|
21
|
+
// guaranteed by the build floor) — so stylesheet-driven `display:none`
|
|
22
|
+
// participates in this check. The remaining gap is viewport emulation:
|
|
23
|
+
// `@media` rules and `matchMedia()` evaluate against the hardcoded
|
|
24
|
+
// 1920×1080 viewport (no resize), so visibility gated on a non-desktop
|
|
25
|
+
// viewport — e.g. a mobile-only CTA under `@media (max-width: …)` —
|
|
26
|
+
// resolves like desktop Chrome at 1920×1080. Keep those specs on a
|
|
27
|
+
// full-layout driver (README's dual-driver setup).
|
|
28
|
+
function isVisible(el) {
|
|
29
|
+
if (!el || el.nodeType !== 1) return false;
|
|
30
|
+
var win = el.ownerDocument.defaultView || window;
|
|
31
|
+
var style = win.getComputedStyle(el);
|
|
32
|
+
if (style.visibility === 'hidden' || style.visibility === 'collapse') return false;
|
|
33
|
+
return el.checkVisibility();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Returns true if the element is obscured at its center point — i.e.
|
|
37
|
+
// hit-testing elementFromPoint at the center returns something that is
|
|
38
|
+
// not the element or its descendant. `visibility:hidden|collapse` is
|
|
39
|
+
// short-circuited explicitly because those elements still produce a
|
|
40
|
+
// layout box (so the rect-zero check below can't catch them); every
|
|
41
|
+
// other "not rendered" case (display:none, [hidden], descendants of
|
|
42
|
+
// either) falls out naturally because getBoundingClientRect returns
|
|
43
|
+
// DOMRect{0,0,0,0} for elements with no layout box.
|
|
44
|
+
function isObscured(el) {
|
|
45
|
+
var doc = el.ownerDocument;
|
|
46
|
+
var win = doc.defaultView || window;
|
|
47
|
+
var style = win.getComputedStyle(el);
|
|
48
|
+
if (style.visibility === 'hidden' || style.visibility === 'collapse') return true;
|
|
49
|
+
var r = el.getBoundingClientRect();
|
|
50
|
+
if (r.width === 0 || r.height === 0) return true;
|
|
51
|
+
var cx = r.left + (r.width / 2);
|
|
52
|
+
var cy = r.top + (r.height / 2);
|
|
53
|
+
var w = win.innerWidth || doc.documentElement.clientWidth;
|
|
54
|
+
var h = win.innerHeight || doc.documentElement.clientHeight;
|
|
55
|
+
if (cx < 0 || cy < 0 || cx > w || cy > h) return true;
|
|
56
|
+
var hit = doc.elementFromPoint(cx, cy);
|
|
57
|
+
if (!hit) return true;
|
|
58
|
+
if (hit === el) return false;
|
|
59
|
+
return !el.contains(hit);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Capybara's `Node#disabled?` is more permissive than CSS `:disabled`:
|
|
63
|
+
// an `<option>` inside a disabled `<select>` or a disabled `<fieldset>`
|
|
64
|
+
// is reported as disabled, even though those don't match CSS `:disabled`
|
|
65
|
+
// per the HTML spec. Mirrors Cuprite's behavior so the shared specs pass.
|
|
66
|
+
function isDisabled(el) {
|
|
67
|
+
if (el.matches && el.matches(':disabled')) return true;
|
|
68
|
+
if ((el.tagName || '').toUpperCase() === 'OPTION') {
|
|
69
|
+
var p = el.parentElement;
|
|
70
|
+
while (p) {
|
|
71
|
+
if (p.disabled) return true;
|
|
72
|
+
p = p.parentElement;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// True if the element is the host of a contenteditable region: it (or any
|
|
79
|
+
// ancestor) has a non-"false" `contenteditable` attribute. Lightpanda's
|
|
80
|
+
// native `HTMLElement.isContentEditable` (PR #2310) is hardwired to return
|
|
81
|
+
// `false` for every element — it has no caret/keyboard editing pipeline —
|
|
82
|
+
// so the IDL property is useless here and we walk ancestors ourselves.
|
|
83
|
+
function isContentEditable(el) {
|
|
84
|
+
var n = el;
|
|
85
|
+
while (n && n.nodeType === 1) {
|
|
86
|
+
if (n.hasAttribute && n.hasAttribute('contenteditable')) {
|
|
87
|
+
var v = (n.getAttribute('contenteditable') || '').toLowerCase();
|
|
88
|
+
return v !== 'false';
|
|
89
|
+
}
|
|
90
|
+
n = n.parentElement;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// visibleText's block-detection tables. A node is block-level if its computed
|
|
96
|
+
// `display` is in BLOCK_DISP or its tag is in BLOCK_TAG (the tag table catches
|
|
97
|
+
// elements whose display we can't resolve and elements that are block by UA
|
|
98
|
+
// default). Hoisted to IIFE scope so they're built once at injection, not
|
|
99
|
+
// re-allocated on every visibleText() call.
|
|
100
|
+
var BLOCK_DISP = { BLOCK:1, FLEX:1, GRID:1, 'LIST-ITEM':1, TABLE:1, 'TABLE-ROW':1,
|
|
101
|
+
'TABLE-CAPTION':1, 'TABLE-CELL':1 };
|
|
102
|
+
var BLOCK_TAG = { ADDRESS:1, ARTICLE:1, ASIDE:1, BLOCKQUOTE:1, DETAILS:1, DIALOG:1,
|
|
103
|
+
DIV:1, DL:1, DT:1, DD:1, FIELDSET:1, FIGCAPTION:1, FIGURE:1,
|
|
104
|
+
FOOTER:1, FORM:1, H1:1, H2:1, H3:1, H4:1, H5:1, H6:1, HEADER:1,
|
|
105
|
+
HGROUP:1, HR:1, LI:1, MAIN:1, NAV:1, OL:1, P:1, PRE:1, SECTION:1,
|
|
106
|
+
TABLE:1, TR:1, UL:1 };
|
|
107
|
+
|
|
108
|
+
// Walk descendants and accumulate text from visible nodes only. Inserts
|
|
109
|
+
// newlines around block-display containers so paragraphs/lists render with
|
|
110
|
+
// natural breaks (Capybara expects this from Chrome's innerText).
|
|
111
|
+
// Lightpanda's innerText returns textContent verbatim (no rendering, so no
|
|
112
|
+
// hidden-descendant filtering), so we DIY the visibility filtering here.
|
|
113
|
+
function visibleText(el) {
|
|
114
|
+
// Collapse runs of ASCII whitespace (preserving NBSP) to a single space —
|
|
115
|
+
// matches Chrome's innerText whitespace handling for text nodes.
|
|
116
|
+
function normText(s) {
|
|
117
|
+
return s.replace(/[\t\n\r\f\v ]+/g, ' ');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function walk(node) {
|
|
121
|
+
if (node.nodeType === 3) return normText(node.nodeValue);
|
|
122
|
+
// DocumentFragment / ShadowRoot — no element of its own to test
|
|
123
|
+
// for visibility, just walk children.
|
|
124
|
+
if (node.nodeType === 11) {
|
|
125
|
+
var fout = '';
|
|
126
|
+
for (var k = 0; k < node.childNodes.length; k++) fout += walk(node.childNodes[k]);
|
|
127
|
+
return fout;
|
|
128
|
+
}
|
|
129
|
+
if (node.nodeType !== 1) return '';
|
|
130
|
+
if (!isVisible(node)) return '';
|
|
131
|
+
var tag = (node.tagName || '').toUpperCase();
|
|
132
|
+
if (tag === 'TEXTAREA') return node.value || '';
|
|
133
|
+
if (tag === 'BR') return '\n';
|
|
134
|
+
var win = node.ownerDocument.defaultView || window;
|
|
135
|
+
var style = win.getComputedStyle(node);
|
|
136
|
+
var disp = (style.display || '').toUpperCase();
|
|
137
|
+
var isBlock = BLOCK_DISP[disp] || BLOCK_TAG[tag];
|
|
138
|
+
var out = '';
|
|
139
|
+
for (var i = 0; i < node.childNodes.length; i++) {
|
|
140
|
+
out += walk(node.childNodes[i]);
|
|
141
|
+
}
|
|
142
|
+
// Block-level elements get wrapped in \n…\n only when they actually
|
|
143
|
+
// contribute visible text. An empty <div> between two inline siblings
|
|
144
|
+
// would otherwise introduce a phantom line break that Chrome's
|
|
145
|
+
// innerText algorithm collapses out (required line breaks around
|
|
146
|
+
// empty blocks coalesce in the line-collapse pass).
|
|
147
|
+
if (isBlock && /\S/.test(out)) out = '\n' + out + '\n';
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return walk(el);
|
|
152
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// --- Turbo activity tracking ---
|
|
2
|
+
// Tracks pending Turbo operations so the driver can wait for Turbo to settle.
|
|
3
|
+
// Inspired by the CapybaraLockstep approach for stabilizing Turbo integration tests.
|
|
4
|
+
// Events are not perfectly symmetrical in Turbo, so we track multiple pairs
|
|
5
|
+
// and use a counter to handle overlapping operations.
|
|
6
|
+
//
|
|
7
|
+
// Transitions across 0 emit `__lightpanda_turbo_busy` / `__lightpanda_turbo_idle`
|
|
8
|
+
// sentinels via console.debug. Browser#wait_for_turbo subscribes to those
|
|
9
|
+
// sentinels (Runtime.consoleAPICalled) and toggles a Concurrent::Event so the
|
|
10
|
+
// Ruby side can wait event-driven instead of polling.
|
|
11
|
+
//
|
|
12
|
+
// Pages without Turbo never trigger _turboStart, so no sentinels fire and the
|
|
13
|
+
// Ruby Event stays set (idle by default) — wait_for_turbo returns immediately.
|
|
14
|
+
var _pendingTurboOps = 0;
|
|
15
|
+
function _signalTurbo(state) {
|
|
16
|
+
try { console.debug('__lightpanda_turbo_' + state); } catch (e) {}
|
|
17
|
+
}
|
|
18
|
+
function _turboStart() {
|
|
19
|
+
_pendingTurboOps++;
|
|
20
|
+
if (_pendingTurboOps === 1) _signalTurbo('busy');
|
|
21
|
+
}
|
|
22
|
+
function _turboEnd() {
|
|
23
|
+
if (_pendingTurboOps > 0) {
|
|
24
|
+
_pendingTurboOps--;
|
|
25
|
+
if (_pendingTurboOps === 0) _signalTurbo('idle');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Fetch requests (covers Drive, Frames, and Form submission fetches)
|
|
30
|
+
document.addEventListener('turbo:before-fetch-request', _turboStart);
|
|
31
|
+
document.addEventListener('turbo:before-fetch-response', _turboEnd);
|
|
32
|
+
document.addEventListener('turbo:fetch-request-error', _turboEnd);
|
|
33
|
+
|
|
34
|
+
// Form submissions (can outlast their underlying fetch)
|
|
35
|
+
document.addEventListener('turbo:submit-start', _turboStart);
|
|
36
|
+
document.addEventListener('turbo:submit-end', _turboEnd);
|
|
37
|
+
|
|
38
|
+
// Frame rendering (can outlast the fetch that triggered it)
|
|
39
|
+
document.addEventListener('turbo:before-frame-render', _turboStart);
|
|
40
|
+
document.addEventListener('turbo:frame-render', _turboEnd);
|
|
41
|
+
|
|
42
|
+
// Stream rendering (no symmetric end event — wrap the render function)
|
|
43
|
+
document.addEventListener('turbo:before-stream-render', function(event) {
|
|
44
|
+
_turboStart();
|
|
45
|
+
if (event.detail && event.detail.render) {
|
|
46
|
+
var originalRender = event.detail.render;
|
|
47
|
+
event.detail.render = function(streamElement) {
|
|
48
|
+
var result = originalRender(streamElement);
|
|
49
|
+
if (result && typeof result.then === 'function') {
|
|
50
|
+
return result.finally(_turboEnd);
|
|
51
|
+
}
|
|
52
|
+
_turboEnd();
|
|
53
|
+
return result;
|
|
54
|
+
};
|
|
55
|
+
} else {
|
|
56
|
+
_turboEnd();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Drive page visits: turbo:load fires after the page is fully rendered.
|
|
61
|
+
// Also serves as a safety reset — clears any counter leaks from aborted fetches.
|
|
62
|
+
// Always re-signal idle so the Ruby Event re-arms even if some `_turboEnd`
|
|
63
|
+
// call dropped on the floor mid-navigation.
|
|
64
|
+
document.addEventListener('turbo:load', function() {
|
|
65
|
+
_pendingTurboOps = 0;
|
|
66
|
+
_signalTurbo('idle');
|
|
67
|
+
});
|
|
@@ -14,6 +14,7 @@ module Capybara
|
|
|
14
14
|
enter: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
|
|
15
15
|
shift: { key: "Shift", code: "ShiftLeft", keyCode: 16 },
|
|
16
16
|
control: { key: "Control", code: "ControlLeft", keyCode: 17 },
|
|
17
|
+
ctrl: { key: "Control", code: "ControlLeft", keyCode: 17 },
|
|
17
18
|
alt: { key: "Alt", code: "AltLeft", keyCode: 18 },
|
|
18
19
|
pause: { key: "Pause", code: "Pause", keyCode: 19 },
|
|
19
20
|
escape: { key: "Escape", code: "Escape", keyCode: 27 },
|
|
@@ -120,12 +121,14 @@ module Capybara
|
|
|
120
121
|
def press_modifier(mod, active_mods)
|
|
121
122
|
return if active_mods.include?(mod)
|
|
122
123
|
|
|
123
|
-
|
|
124
|
+
# key_definition (not KEYS[mod]) so a MODIFIERS entry missing its
|
|
125
|
+
# KEYS counterpart fails as ArgumentError, not NoMethodError on nil.
|
|
126
|
+
send_key_event("keyDown", key_definition(mod))
|
|
124
127
|
active_mods << mod
|
|
125
128
|
end
|
|
126
129
|
|
|
127
130
|
def release_modifiers(active_mods)
|
|
128
|
-
active_mods.reverse_each { |m| send_key_event("keyUp",
|
|
131
|
+
active_mods.reverse_each { |m| send_key_event("keyUp", key_definition(m)) }
|
|
129
132
|
active_mods.clear
|
|
130
133
|
end
|
|
131
134
|
|
|
@@ -133,7 +136,7 @@ module Capybara
|
|
|
133
136
|
return dispatch_key(key) if active_mods.empty?
|
|
134
137
|
|
|
135
138
|
modifier_value = active_mods.sum { |m| MODIFIERS[m] }
|
|
136
|
-
|
|
139
|
+
raw_dispatch(key_definition(key), modifiers: modifier_value)
|
|
137
140
|
end
|
|
138
141
|
|
|
139
142
|
def dispatch_char_with_mods(char, active_mods)
|
|
@@ -144,31 +147,28 @@ module Capybara
|
|
|
144
147
|
end
|
|
145
148
|
|
|
146
149
|
def dispatch_key(key)
|
|
147
|
-
|
|
148
|
-
raw_dispatch(definition)
|
|
150
|
+
raw_dispatch(key_definition(key))
|
|
149
151
|
end
|
|
150
152
|
|
|
151
153
|
def dispatch_char(char)
|
|
152
154
|
@browser.page_command("Input.insertText", text: char)
|
|
153
155
|
end
|
|
154
156
|
|
|
157
|
+
# An array groups modifiers with the keys they modify —
|
|
158
|
+
# `send_keys([:ctrl, "a"])` — scoped to the array instead of the rest
|
|
159
|
+
# of the call. Same press/dispatch/release machinery as held modifiers.
|
|
155
160
|
def type_with_modifiers(keys)
|
|
156
|
-
modifiers,
|
|
157
|
-
modifier_value = modifiers.sum { |m| MODIFIERS[m] }
|
|
161
|
+
modifiers, rest = keys.partition { |k| k.is_a?(Symbol) && MODIFIERS.key?(k) }
|
|
158
162
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
when Symbol
|
|
167
|
-
definition = KEYS.fetch(key) { raise ArgumentError, "Unknown key: #{key.inspect}" }
|
|
168
|
-
raw_dispatch(definition, modifiers: modifier_value)
|
|
169
|
-
when String
|
|
170
|
-
key.each_char { |char| dispatch_modified_char(char, modifier_value, modifiers) }
|
|
163
|
+
active_mods = []
|
|
164
|
+
modifiers.each { |m| press_modifier(m, active_mods) }
|
|
165
|
+
rest.each do |key|
|
|
166
|
+
case key
|
|
167
|
+
when Symbol then dispatch_key_with_mods(key, active_mods)
|
|
168
|
+
when String then key.each_char { |char| dispatch_char_with_mods(char, active_mods) }
|
|
169
|
+
end
|
|
171
170
|
end
|
|
171
|
+
release_modifiers(active_mods)
|
|
172
172
|
end
|
|
173
173
|
|
|
174
174
|
def dispatch_modified_char(char, modifier_value, modifiers)
|
|
@@ -177,6 +177,10 @@ module Capybara
|
|
|
177
177
|
send_key_event("keyUp", { key: text }, modifiers: modifier_value)
|
|
178
178
|
end
|
|
179
179
|
|
|
180
|
+
def key_definition(key)
|
|
181
|
+
KEYS.fetch(key) { raise ArgumentError, "Unknown key: #{key.inspect}" }
|
|
182
|
+
end
|
|
183
|
+
|
|
180
184
|
def raw_dispatch(definition, modifiers: 0)
|
|
181
185
|
type = definition[:text] ? "keyDown" : "rawKeyDown"
|
|
182
186
|
send_key_event(type, definition, modifiers: modifiers)
|