capybara-lightpanda 0.1.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 +7 -0
- data/CHANGELOG.md +50 -0
- data/LICENSE.txt +27 -0
- data/NOTICE.md +101 -0
- data/README.md +215 -0
- data/lib/capybara/lightpanda/binary.rb +190 -0
- data/lib/capybara/lightpanda/browser.rb +963 -0
- data/lib/capybara/lightpanda/client/subscriber.rb +44 -0
- data/lib/capybara/lightpanda/client/web_socket.rb +160 -0
- data/lib/capybara/lightpanda/client.rb +124 -0
- data/lib/capybara/lightpanda/cookies.rb +181 -0
- data/lib/capybara/lightpanda/driver.rb +252 -0
- data/lib/capybara/lightpanda/errors.rb +76 -0
- data/lib/capybara/lightpanda/frame.rb +33 -0
- data/lib/capybara/lightpanda/javascripts/index.js +1108 -0
- data/lib/capybara/lightpanda/keyboard.rb +142 -0
- data/lib/capybara/lightpanda/logger.rb +37 -0
- data/lib/capybara/lightpanda/network.rb +92 -0
- data/lib/capybara/lightpanda/node.rb +726 -0
- data/lib/capybara/lightpanda/options.rb +63 -0
- data/lib/capybara/lightpanda/process.rb +252 -0
- data/lib/capybara/lightpanda/utils/event.rb +37 -0
- data/lib/capybara/lightpanda/version.rb +7 -0
- data/lib/capybara/lightpanda/xpath_polyfill.rb +10 -0
- data/lib/capybara-lightpanda.rb +42 -0
- metadata +119 -0
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Capybara
|
|
4
|
+
module Lightpanda
|
|
5
|
+
class Node < ::Capybara::Driver::Node
|
|
6
|
+
MOVING_WAIT_DELAY = ENV.fetch("LIGHTPANDA_NODE_MOVING_WAIT", 0.01).to_f
|
|
7
|
+
MOVING_WAIT_ATTEMPTS = ENV.fetch("LIGHTPANDA_NODE_MOVING_ATTEMPTS", 50).to_i
|
|
8
|
+
|
|
9
|
+
attr_reader :remote_object_id
|
|
10
|
+
|
|
11
|
+
def initialize(driver, remote_object_id)
|
|
12
|
+
super
|
|
13
|
+
@remote_object_id = remote_object_id
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def text
|
|
17
|
+
ensure_connected
|
|
18
|
+
call("function() { return this.textContent }")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def all_text
|
|
22
|
+
ensure_connected
|
|
23
|
+
filter_text(call("function() { return this.textContent }"))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Lightpanda's innerText returns textContent verbatim (no rendering, so no
|
|
27
|
+
# hidden-descendant filtering). Walk descendants ourselves, skipping nodes
|
|
28
|
+
# that fail VISIBLE_JS, and emit newlines around block-display elements
|
|
29
|
+
# (the part of innerText behavior we still need).
|
|
30
|
+
def visible_text
|
|
31
|
+
ensure_connected
|
|
32
|
+
call(VISIBLE_TEXT_JS).to_s
|
|
33
|
+
.gsub(/\A[[:space:]&&[^\u00A0]]+/, "")
|
|
34
|
+
.gsub(/[[:space:]&&[^\u00A0]]+\z/, "")
|
|
35
|
+
.gsub(/[ \t\f\v]+/, " ")
|
|
36
|
+
.gsub(/[ \t\f\v]*\n[ \t\f\v\n]*/, "\n")
|
|
37
|
+
.tr("\u00A0", " ")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def rect
|
|
41
|
+
call(GET_RECT_JS)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def obscured?
|
|
45
|
+
call(OBSCURED_JS)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Returns true when the element's bounding rect has changed between two
|
|
49
|
+
# samples taken `delay` seconds apart. Lightpanda has no real animation
|
|
50
|
+
# frame loop so most "movement" is JS-driven (style mutations); this
|
|
51
|
+
# works because getBoundingClientRect reflects those mutations.
|
|
52
|
+
def moving?(delay: MOVING_WAIT_DELAY)
|
|
53
|
+
previous = rect
|
|
54
|
+
sleep(delay)
|
|
55
|
+
previous != rect
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Block until the element's rect stabilises across two consecutive
|
|
59
|
+
# samples or `attempts` polls have elapsed (whichever first). Returns
|
|
60
|
+
# the last rect read; never raises. Mirrors ferrum's wait_for_stop_moving
|
|
61
|
+
# but no NodeMovingError because Lightpanda has no rendering loop, so a
|
|
62
|
+
# caller silently proceeding with the last rect is the right default.
|
|
63
|
+
def wait_for_stop_moving(delay: MOVING_WAIT_DELAY, attempts: MOVING_WAIT_ATTEMPTS)
|
|
64
|
+
previous = rect
|
|
65
|
+
attempts.times do
|
|
66
|
+
sleep(delay)
|
|
67
|
+
current = rect
|
|
68
|
+
return current if current == previous
|
|
69
|
+
|
|
70
|
+
previous = current
|
|
71
|
+
end
|
|
72
|
+
previous
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def shadow_root
|
|
76
|
+
result = driver.browser.with_default_context_wait do
|
|
77
|
+
driver.browser.call_function_on(
|
|
78
|
+
@remote_object_id,
|
|
79
|
+
"function() { return this.shadowRoot }",
|
|
80
|
+
return_by_value: false
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
return nil unless result.is_a?(Hash) && result["objectId"]
|
|
84
|
+
|
|
85
|
+
self.class.new(driver, result["objectId"])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Smart property/attribute getter (Cuprite pattern).
|
|
89
|
+
# Returns resolved URLs for src/href, raw attributes otherwise.
|
|
90
|
+
def [](name)
|
|
91
|
+
call(PROPERTY_OR_ATTRIBUTE_JS, name.to_s)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def value
|
|
95
|
+
call(GET_VALUE_JS)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def style(styles)
|
|
99
|
+
styles.to_h { |style| [style, call(GET_STYLE_JS, style)] }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def click(_keys = [], **_options)
|
|
103
|
+
call(CLICK_JS)
|
|
104
|
+
driver.browser.wait_for_idle
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def right_click(_keys = [], **_options)
|
|
108
|
+
call("function() { this.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true})) }")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def double_click(_keys = [], **_options)
|
|
112
|
+
call("function() { this.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true})) }")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def hover
|
|
116
|
+
call("function() { this.dispatchEvent(new MouseEvent('mouseover', {bubbles: true, cancelable: true})) }")
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def set(value, **_options)
|
|
120
|
+
case tag_name
|
|
121
|
+
when "input"
|
|
122
|
+
fill_input(value)
|
|
123
|
+
when "textarea"
|
|
124
|
+
call(SET_VALUE_JS, truncate_to_maxlength(value.to_s))
|
|
125
|
+
else
|
|
126
|
+
# `contenteditable` cascades through descendants. Check
|
|
127
|
+
# `isContentEditable`, then fall back to walking ancestors for
|
|
128
|
+
# `contenteditable` since Lightpanda doesn't expose the property on
|
|
129
|
+
# every element. EDITABLE_HOST_JS encapsulates that check.
|
|
130
|
+
call("function(v) { this.innerHTML = v }", value.to_s) if call(EDITABLE_HOST_JS)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def select_option
|
|
135
|
+
call(SELECT_OPTION_JS)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def unselect_option
|
|
139
|
+
unless call("function() {
|
|
140
|
+
var s = this.parentElement;
|
|
141
|
+
while (s && (s.tagName || '').toUpperCase() !== 'SELECT') s = s.parentElement;
|
|
142
|
+
return !!(s && s.multiple);
|
|
143
|
+
}")
|
|
144
|
+
raise Capybara::UnselectNotAllowed, "Cannot unselect option from single select box."
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
call(UNSELECT_OPTION_JS)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def send_keys(*)
|
|
151
|
+
call("function() { this.focus() }")
|
|
152
|
+
driver.browser.keyboard.type(*)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def tag_name
|
|
156
|
+
# ShadowRoot/DocumentFragment have no tagName; report a stable label so
|
|
157
|
+
# Capybara's failure messages can render `tag="ShadowRoot"`.
|
|
158
|
+
# Memoized: an objectId points to a single DOM node whose tagName is
|
|
159
|
+
# immutable for that node's lifetime.
|
|
160
|
+
@tag_name ||= call("function() {
|
|
161
|
+
if (this.nodeType === 11) return 'ShadowRoot';
|
|
162
|
+
return this.tagName ? this.tagName.toLowerCase() : '';
|
|
163
|
+
}")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def visible?
|
|
167
|
+
call(VISIBLE_JS)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def checked?
|
|
171
|
+
call("function() { return this.checked }")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def selected?
|
|
175
|
+
call("function() { return !!this.selected }")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def disabled?
|
|
179
|
+
call(DISABLED_JS)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def readonly?
|
|
183
|
+
call("function() { return this.readOnly }")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def multiple?
|
|
187
|
+
call("function() { return this.multiple }")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def path
|
|
191
|
+
call(GET_PATH_JS)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def find_xpath(selector)
|
|
195
|
+
object_ids = driver.browser.find_within(@remote_object_id, "xpath", selector)
|
|
196
|
+
object_ids.map { |oid| self.class.new(driver, oid) }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def find_css(selector)
|
|
200
|
+
object_ids = driver.browser.find_within(@remote_object_id, "css", selector)
|
|
201
|
+
object_ids.map { |oid| self.class.new(driver, oid) }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Equality compares the underlying DOM node via backendNodeId, the only
|
|
205
|
+
# identity that's stable across CDP calls. NO fast path on remote_object_id:
|
|
206
|
+
# two wrappers with the same remote_object_id can resolve to different
|
|
207
|
+
# backendNodeIds (one cached at 42, the other still nil from a transient
|
|
208
|
+
# describeNode failure), and a remote-id fast path there would return `true`
|
|
209
|
+
# while `#hash` returned different values, violating the hash contract.
|
|
210
|
+
# When either side fails to resolve, the nodes are treated as not equal so
|
|
211
|
+
# stale wrappers don't collapse onto each other.
|
|
212
|
+
def ==(other)
|
|
213
|
+
return false unless other.is_a?(self.class)
|
|
214
|
+
|
|
215
|
+
left = backend_node_id
|
|
216
|
+
right = other.backend_node_id
|
|
217
|
+
!left.nil? && left == right
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
alias eql? ==
|
|
221
|
+
|
|
222
|
+
# Hash on backendNodeId so equal nodes always hash the same. When
|
|
223
|
+
# describeNode fails (returns nil) the bucket collapses to `nil.hash`;
|
|
224
|
+
# combined with `==` returning false for nil-resolved nodes, Set/Hash
|
|
225
|
+
# membership stays consistent (collisions are allowed for unequal objects).
|
|
226
|
+
def hash
|
|
227
|
+
backend_node_id.hash
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def backend_node_id
|
|
231
|
+
@backend_node_id ||= driver.browser.backend_node_id(@remote_object_id)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private
|
|
235
|
+
|
|
236
|
+
# Capybara's `automatic_reload` re-runs the original query when an element
|
|
237
|
+
# access raises one of the driver's `invalid_element_errors`. After a DOM
|
|
238
|
+
# mutation like `replaceWith`, our cached objectId still resolves to the
|
|
239
|
+
# detached node, so reads succeed (with stale data) and the auto-reload
|
|
240
|
+
# never fires. Detect detachment via `isConnected` and raise so the
|
|
241
|
+
# synchronize-loop notices and triggers a re-find.
|
|
242
|
+
def ensure_connected
|
|
243
|
+
connected = call("function() { return this.isConnected }")
|
|
244
|
+
return if connected
|
|
245
|
+
|
|
246
|
+
raise ObsoleteNode.new(self, "Node is no longer attached to the document")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Trigger implicit form submission via the IMPLICIT_SUBMIT_JS pipeline
|
|
250
|
+
# (same fetch+swap as CLICK_JS, but without a submitter).
|
|
251
|
+
def implicit_submit
|
|
252
|
+
call(IMPLICIT_SUBMIT_JS)
|
|
253
|
+
driver.browser.wait_for_idle
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
TEXT_LIKE_INPUT_TYPES = %w[text email password url tel search number].freeze
|
|
257
|
+
private_constant :TEXT_LIKE_INPUT_TYPES
|
|
258
|
+
|
|
259
|
+
def fill_input(value)
|
|
260
|
+
type = self["type"]
|
|
261
|
+
case type
|
|
262
|
+
when "checkbox", "radio"
|
|
263
|
+
call(SET_CHECKBOX_JS, value ? true : false)
|
|
264
|
+
when "file"
|
|
265
|
+
raise NotImplementedError, "File uploads not yet supported by Lightpanda"
|
|
266
|
+
when "date"
|
|
267
|
+
call(SET_VALUE_JS, format_date_value(value))
|
|
268
|
+
when "time"
|
|
269
|
+
call(SET_VALUE_JS, format_time_value(value))
|
|
270
|
+
when "datetime-local"
|
|
271
|
+
call(SET_VALUE_JS, format_datetime_value(value))
|
|
272
|
+
else
|
|
273
|
+
fill_text_input(type, value.to_s)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# HTML implicit-submission: a trailing \n in a text-like input is like the
|
|
278
|
+
# user pressing Enter — submits the form when there's a default submit
|
|
279
|
+
# button OR exactly one text control. Strip the \n, set the value, then
|
|
280
|
+
# route through IMPLICIT_SUBMIT_JS so CLICK_JS's fetch+swap runs.
|
|
281
|
+
def fill_text_input(type, str)
|
|
282
|
+
if str.end_with?("\n") && TEXT_LIKE_INPUT_TYPES.include?(type)
|
|
283
|
+
call(SET_VALUE_JS, truncate_to_maxlength(str.chomp))
|
|
284
|
+
implicit_submit
|
|
285
|
+
else
|
|
286
|
+
call(SET_VALUE_JS, truncate_to_maxlength(str))
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Format helpers for Date/Time/DateTime values passed to date/time/datetime-local
|
|
291
|
+
# inputs. Mirror Capybara::Selenium's SettableValue so a Ruby Time fills the
|
|
292
|
+
# field with the same string the user would type.
|
|
293
|
+
def format_date_value(value)
|
|
294
|
+
return value.to_s if value.is_a?(String) || !value.respond_to?(:to_date)
|
|
295
|
+
|
|
296
|
+
value.to_date.iso8601
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def format_time_value(value)
|
|
300
|
+
return value.to_s if value.is_a?(String) || !value.respond_to?(:to_time)
|
|
301
|
+
|
|
302
|
+
value.to_time.strftime("%H:%M")
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def format_datetime_value(value)
|
|
306
|
+
return value.to_s if value.is_a?(String) || !value.respond_to?(:to_time)
|
|
307
|
+
|
|
308
|
+
value.to_time.strftime("%Y-%m-%dT%H:%M")
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# `maxlength` only constrains user typing, not direct value assignment, but
|
|
312
|
+
# Selenium-style drivers truncate to match what a user would have ended up
|
|
313
|
+
# with. Honor it explicitly so Capybara-shared specs behave the same.
|
|
314
|
+
def truncate_to_maxlength(str)
|
|
315
|
+
max = self["maxlength"]
|
|
316
|
+
return str unless max
|
|
317
|
+
|
|
318
|
+
n = max.to_i
|
|
319
|
+
n.positive? ? str[0, n] : str
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Whitespace-normalized text (Cuprite pattern). Capybara's text matchers compare
|
|
323
|
+
# against this, and Lightpanda's textContent preserves source-template whitespace
|
|
324
|
+
# differently than Chrome — without normalization, multi-line fixtures fail
|
|
325
|
+
# `text: "Line\nLine"` matchers.
|
|
326
|
+
def filter_text(text)
|
|
327
|
+
text.to_s
|
|
328
|
+
.gsub(/[\u200B\u200E\u200F]/, "")
|
|
329
|
+
.gsub(/[ \n\f\t\v\u2028\u2029]+/, " ")
|
|
330
|
+
.gsub(/\A[[:space:]&&[^\u00A0]]+/, "")
|
|
331
|
+
.gsub(/[[:space:]&&[^\u00A0]]+\z/, "")
|
|
332
|
+
.tr("\u00A0", " ")
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Centralized command dispatch via Runtime.callFunctionOn.
|
|
336
|
+
# The function runs with `this` bound to the DOM element by CDP.
|
|
337
|
+
# JS bodies may reference `_lightpanda.*` helpers — they're registered via
|
|
338
|
+
# Page.addScriptToEvaluateOnNewDocument in every document (top frame and
|
|
339
|
+
# iframes alike), so the namespace is available wherever `this` lives.
|
|
340
|
+
def call(function_declaration, *args)
|
|
341
|
+
driver.browser.with_default_context_wait do
|
|
342
|
+
driver.browser.call_function_on(@remote_object_id, function_declaration, *args)
|
|
343
|
+
end
|
|
344
|
+
rescue BrowserError => e
|
|
345
|
+
case e.message
|
|
346
|
+
when /MouseEventFailed/i
|
|
347
|
+
raise MouseEventFailed.new(self, e.response&.dig("message"))
|
|
348
|
+
else
|
|
349
|
+
raise
|
|
350
|
+
end
|
|
351
|
+
rescue JavaScriptError => e
|
|
352
|
+
case e.class_name
|
|
353
|
+
when "InvalidSelector"
|
|
354
|
+
raise InvalidSelector.new(e.message, nil, args.first)
|
|
355
|
+
else
|
|
356
|
+
raise
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Form-submit click bypass for Lightpanda.
|
|
361
|
+
#
|
|
362
|
+
# Lightpanda's `form.submit()` does NOT navigate — it parses, validates, but
|
|
363
|
+
# never issues an HTTP request. And `document.write()` is a no-op (verified
|
|
364
|
+
# 2026-04-26: body length unchanged after open/write/close). So both the
|
|
365
|
+
# native submit path and the previous `fetch+document.write` workaround leave
|
|
366
|
+
# the page on the original URL with the form still rendered.
|
|
367
|
+
#
|
|
368
|
+
# For submit-button clicks we instead `fetch` the form action ourselves,
|
|
369
|
+
# parse the response with `DOMParser`, swap `document.body.innerHTML`, and
|
|
370
|
+
# `history.replaceState` the response URL. `_lightpanda` and the XPath
|
|
371
|
+
# polyfill survive the swap because we don't reload the document.
|
|
372
|
+
#
|
|
373
|
+
# For non-submit elements (links, regular buttons, anchors) we fall through
|
|
374
|
+
# to native `this.click()`. Turbo Drive's click handler — when Turbo is
|
|
375
|
+
# loaded — intercepts that natively, runs its own fetch+replaceWith, and
|
|
376
|
+
# works fine on Lightpanda after the `#id` rewriter polyfill in index.js.
|
|
377
|
+
CLICK_JS = <<~JS
|
|
378
|
+
function() {
|
|
379
|
+
var tag = this.tagName.toLowerCase();
|
|
380
|
+
var type = (this.type || '').toLowerCase();
|
|
381
|
+
// <button> with no `type` attribute defaults to submit per HTML.
|
|
382
|
+
var isSubmitBtn = (tag === 'button' && (type === '' || type === 'submit')) ||
|
|
383
|
+
(tag === 'input' && (type === 'submit' || type === 'image'));
|
|
384
|
+
var form = isSubmitBtn ? this.form : null;
|
|
385
|
+
// Lightpanda doesn't propagate label clicks to their associated
|
|
386
|
+
// form control the way browsers do, so when Capybara clicks a
|
|
387
|
+
// <label> for a hidden checkbox/radio (automatic_label_click)
|
|
388
|
+
// we explicitly forward the click.
|
|
389
|
+
if (tag === 'label') {
|
|
390
|
+
this.click();
|
|
391
|
+
var ctrl = null;
|
|
392
|
+
var forId = this.getAttribute('for');
|
|
393
|
+
if (forId) ctrl = this.ownerDocument.getElementById(forId);
|
|
394
|
+
if (!ctrl) ctrl = this.querySelector('input, select, textarea');
|
|
395
|
+
if (ctrl) {
|
|
396
|
+
var ctype = (ctrl.type || '').toLowerCase();
|
|
397
|
+
if (ctype === 'checkbox' || ctype === 'radio') ctrl.click();
|
|
398
|
+
}
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (!form) {
|
|
402
|
+
this.click();
|
|
403
|
+
// Lightpanda doesn't toggle <details> when its <summary> is clicked.
|
|
404
|
+
// Walk up to the nearest <details> (only if click hit a summary
|
|
405
|
+
// and we haven't been preventDefault'd by user JS) and flip `open`.
|
|
406
|
+
if (tag === 'summary') {
|
|
407
|
+
var d = this.parentNode;
|
|
408
|
+
while (d && d.nodeType === 1 && d.tagName.toLowerCase() !== 'details') {
|
|
409
|
+
d = d.parentNode;
|
|
410
|
+
}
|
|
411
|
+
if (d && d.tagName && d.tagName.toLowerCase() === 'details') {
|
|
412
|
+
d.open = !d.open;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Fire the submit event first so user JS handlers can intercept and
|
|
419
|
+
// preventDefault — but skip this when Turbo is loaded, because Turbo's
|
|
420
|
+
// submit pipeline throws on Lightpanda (and the gem already handles the
|
|
421
|
+
// navigation below). Turbo's link-click pipeline still works fine.
|
|
422
|
+
if (typeof Turbo === 'undefined') {
|
|
423
|
+
var ev;
|
|
424
|
+
if (typeof SubmitEvent === 'function') {
|
|
425
|
+
ev = new SubmitEvent('submit', { bubbles: true, cancelable: true, submitter: this });
|
|
426
|
+
} else {
|
|
427
|
+
ev = new Event('submit', { bubbles: true, cancelable: true });
|
|
428
|
+
ev.submitter = this;
|
|
429
|
+
}
|
|
430
|
+
var allowed = form.dispatchEvent(ev);
|
|
431
|
+
if (!allowed) return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// No handler intercepted — fetch + swap ourselves because Lightpanda's
|
|
435
|
+
// native form.submit() does not navigate.
|
|
436
|
+
// Pass the submitter so the button is serialized at its document
|
|
437
|
+
// position alongside the form's other named controls.
|
|
438
|
+
var formData;
|
|
439
|
+
try { formData = new FormData(form, this); }
|
|
440
|
+
catch (e) { formData = new FormData(form); }
|
|
441
|
+
var submitterName = this.getAttribute('name');
|
|
442
|
+
if (submitterName && !formData.has(submitterName)) {
|
|
443
|
+
// Lightpanda's FormData(form, submitter) may omit a <button> with no
|
|
444
|
+
// explicit value attribute; HTML says the value falls back to
|
|
445
|
+
// textContent, so feed that in ourselves when the entry is missing.
|
|
446
|
+
var submitterValue = this.getAttribute('value');
|
|
447
|
+
if (submitterValue === null) {
|
|
448
|
+
submitterValue = (tag === 'button') ? (this.textContent || '') : '';
|
|
449
|
+
}
|
|
450
|
+
formData.append(submitterName, submitterValue);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
var action = this.getAttribute('formaction') || form.getAttribute('action') || window.location.href;
|
|
454
|
+
try { action = new URL(action, window.location.href).href; } catch (e) {}
|
|
455
|
+
var method = (this.getAttribute('formmethod') || form.getAttribute('method') || 'GET').toUpperCase();
|
|
456
|
+
|
|
457
|
+
var enctype = (this.getAttribute('formenctype') ||
|
|
458
|
+
form.getAttribute('enctype') ||
|
|
459
|
+
'application/x-www-form-urlencoded').toLowerCase();
|
|
460
|
+
// Lightpanda's URLSearchParams.toString() drops the `=` when the value
|
|
461
|
+
// is an empty string (`{key: ""}` serializes as `key`, not `key=`),
|
|
462
|
+
// which makes the server parse the field as nil instead of "". Lightpanda
|
|
463
|
+
// also doesn't perform the HTML-spec LF→CRLF normalization for textarea
|
|
464
|
+
// values during form submission. Build the query string by hand so both
|
|
465
|
+
// round-trip correctly.
|
|
466
|
+
var formEncode = function(fd) {
|
|
467
|
+
var pairs = [];
|
|
468
|
+
for (var entry of fd.entries()) {
|
|
469
|
+
var value = entry[1];
|
|
470
|
+
if (typeof value === 'string') {
|
|
471
|
+
// Normalize line endings to CRLF per HTML form-data set spec.
|
|
472
|
+
value = value.replace(/\\r\\n|\\r|\\n/g, '\\r\\n');
|
|
473
|
+
}
|
|
474
|
+
pairs.push(encodeURIComponent(entry[0]).replace(/%20/g, '+') +
|
|
475
|
+
'=' +
|
|
476
|
+
encodeURIComponent(value).replace(/%20/g, '+'));
|
|
477
|
+
}
|
|
478
|
+
return pairs.join('&');
|
|
479
|
+
};
|
|
480
|
+
var opts = { method: method, credentials: 'same-origin', redirect: 'follow' };
|
|
481
|
+
if (method === 'GET') {
|
|
482
|
+
var sep = action.indexOf('?') >= 0 ? '&' : '?';
|
|
483
|
+
action = action + sep + formEncode(formData);
|
|
484
|
+
} else if (enctype === 'multipart/form-data') {
|
|
485
|
+
// Pass FormData directly — fetch sets Content-Type with the correct boundary.
|
|
486
|
+
opts.body = formData;
|
|
487
|
+
} else {
|
|
488
|
+
opts.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
|
489
|
+
opts.body = formEncode(formData);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return fetch(action, opts).then(function(r) {
|
|
493
|
+
return r.text().then(function(html) { return { url: r.url, html: html }; });
|
|
494
|
+
}).then(function(o) {
|
|
495
|
+
var doc = new DOMParser().parseFromString(o.html, 'text/html');
|
|
496
|
+
document.title = (doc.title || '');
|
|
497
|
+
document.body.innerHTML = doc.body.innerHTML;
|
|
498
|
+
try { history.replaceState(null, '', o.url); } catch (e) {}
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
JS
|
|
502
|
+
|
|
503
|
+
VISIBLE_JS = "function() { return _lightpanda.isVisible(this); }"
|
|
504
|
+
|
|
505
|
+
VISIBLE_TEXT_JS = "function() { return _lightpanda.visibleText(this); }"
|
|
506
|
+
|
|
507
|
+
PROPERTY_OR_ATTRIBUTE_JS = <<~JS
|
|
508
|
+
function(name) {
|
|
509
|
+
var tag = this.tagName.toLowerCase();
|
|
510
|
+
if ((tag === 'img' && name === 'src') ||
|
|
511
|
+
(tag === 'a' && name === 'href') ||
|
|
512
|
+
(tag === 'link' && name === 'href') ||
|
|
513
|
+
(tag === 'script' && name === 'src') ||
|
|
514
|
+
(tag === 'form' && name === 'action')) {
|
|
515
|
+
if (this.hasAttribute(name)) return this[name];
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
// Boolean attributes: the static `checked`/`selected`/etc.
|
|
519
|
+
// attribute reflects only the default (form-reset) state.
|
|
520
|
+
// The live property tracks the current state, which is what
|
|
521
|
+
// Capybara's `node['checked']` etc. semantics need.
|
|
522
|
+
var BOOL_PROP = { checked: 'checked', selected: 'selected',
|
|
523
|
+
disabled: 'disabled', multiple: 'multiple',
|
|
524
|
+
readonly: 'readOnly', hidden: 'hidden',
|
|
525
|
+
autofocus: 'autofocus', required: 'required' };
|
|
526
|
+
var prop = BOOL_PROP[name.toLowerCase()];
|
|
527
|
+
if (prop && this[prop] !== undefined) return this[prop];
|
|
528
|
+
return this.getAttribute(name);
|
|
529
|
+
}
|
|
530
|
+
JS
|
|
531
|
+
|
|
532
|
+
GET_VALUE_JS = <<~JS
|
|
533
|
+
function() {
|
|
534
|
+
if (this.tagName === 'SELECT' && this.multiple) {
|
|
535
|
+
return Array.from(this.selectedOptions).map(function(o) { return o.value });
|
|
536
|
+
}
|
|
537
|
+
return this.value;
|
|
538
|
+
}
|
|
539
|
+
JS
|
|
540
|
+
|
|
541
|
+
SET_VALUE_JS = <<~JS
|
|
542
|
+
function(value) {
|
|
543
|
+
if (this.readOnly || this.hasAttribute('readonly')) return;
|
|
544
|
+
this.focus();
|
|
545
|
+
this.value = value;
|
|
546
|
+
this.dispatchEvent(new Event('input', {bubbles: true}));
|
|
547
|
+
this.dispatchEvent(new Event('change', {bubbles: true}));
|
|
548
|
+
}
|
|
549
|
+
JS
|
|
550
|
+
|
|
551
|
+
# HTML implicit-submission: when the user presses Enter in a text-like
|
|
552
|
+
# field, the form is submitted if either (a) there's a default submit
|
|
553
|
+
# button, or (b) the form has exactly one submittable text control.
|
|
554
|
+
# `this` is the input. Mirror CLICK_JS's submit pipeline so the gem's
|
|
555
|
+
# fetch+swap path runs (Lightpanda's form.submit() doesn't navigate).
|
|
556
|
+
IMPLICIT_SUBMIT_JS = <<~JS
|
|
557
|
+
function() {
|
|
558
|
+
var form = this.form;
|
|
559
|
+
if (!form) return;
|
|
560
|
+
var hasDefault = !!form.querySelector(
|
|
561
|
+
'button[type=submit], button:not([type]), input[type=submit], input[type=image]'
|
|
562
|
+
);
|
|
563
|
+
if (!hasDefault) {
|
|
564
|
+
var textInputs = form.querySelectorAll(
|
|
565
|
+
'input[type=text], input[type=email], input[type=password], ' +
|
|
566
|
+
'input[type=url], input[type=tel], input[type=search], ' +
|
|
567
|
+
'input[type=number], input:not([type])'
|
|
568
|
+
);
|
|
569
|
+
if (textInputs.length !== 1) return;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (typeof Turbo === 'undefined') {
|
|
573
|
+
var ev;
|
|
574
|
+
if (typeof SubmitEvent === 'function') {
|
|
575
|
+
ev = new SubmitEvent('submit', { bubbles: true, cancelable: true });
|
|
576
|
+
} else {
|
|
577
|
+
ev = new Event('submit', { bubbles: true, cancelable: true });
|
|
578
|
+
}
|
|
579
|
+
var allowed = form.dispatchEvent(ev);
|
|
580
|
+
if (!allowed) return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
var formData = new FormData(form);
|
|
584
|
+
var action = form.getAttribute('action') || window.location.href;
|
|
585
|
+
try { action = new URL(action, window.location.href).href; } catch (e) {}
|
|
586
|
+
var method = (form.getAttribute('method') || 'GET').toUpperCase();
|
|
587
|
+
var enctype = (form.getAttribute('enctype') || 'application/x-www-form-urlencoded').toLowerCase();
|
|
588
|
+
|
|
589
|
+
var formEncode = function(fd) {
|
|
590
|
+
var pairs = [];
|
|
591
|
+
for (var entry of fd.entries()) {
|
|
592
|
+
var value = entry[1];
|
|
593
|
+
if (typeof value === 'string') {
|
|
594
|
+
value = value.replace(/\\r\\n|\\r|\\n/g, '\\r\\n');
|
|
595
|
+
}
|
|
596
|
+
pairs.push(encodeURIComponent(entry[0]).replace(/%20/g, '+') +
|
|
597
|
+
'=' +
|
|
598
|
+
encodeURIComponent(value).replace(/%20/g, '+'));
|
|
599
|
+
}
|
|
600
|
+
return pairs.join('&');
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
var opts = { method: method, credentials: 'same-origin', redirect: 'follow' };
|
|
604
|
+
if (method === 'GET') {
|
|
605
|
+
var sep = action.indexOf('?') >= 0 ? '&' : '?';
|
|
606
|
+
action = action + sep + formEncode(formData);
|
|
607
|
+
} else if (enctype === 'multipart/form-data') {
|
|
608
|
+
opts.body = formData;
|
|
609
|
+
} else {
|
|
610
|
+
opts.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
|
611
|
+
opts.body = formEncode(formData);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return fetch(action, opts).then(function(r) {
|
|
615
|
+
return r.text().then(function(html) { return { url: r.url, html: html }; });
|
|
616
|
+
}).then(function(o) {
|
|
617
|
+
var doc = new DOMParser().parseFromString(o.html, 'text/html');
|
|
618
|
+
document.title = (doc.title || '');
|
|
619
|
+
document.body.innerHTML = doc.body.innerHTML;
|
|
620
|
+
try { history.replaceState(null, '', o.url); } catch (e) {}
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
JS
|
|
624
|
+
|
|
625
|
+
SELECT_OPTION_JS = <<~JS
|
|
626
|
+
function() {
|
|
627
|
+
var sel = this.parentElement;
|
|
628
|
+
while (sel && (sel.tagName || '').toUpperCase() !== 'SELECT') sel = sel.parentElement;
|
|
629
|
+
if (!sel) {
|
|
630
|
+
// Datalist options don't live inside a <select>; toggling
|
|
631
|
+
// `selected` is meaningless. The matching <input list=...>
|
|
632
|
+
// is what should receive the value, but Capybara handles
|
|
633
|
+
// that path itself; just no-op here.
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
if (sel.multiple) {
|
|
637
|
+
this.selected = true;
|
|
638
|
+
} else {
|
|
639
|
+
// Lightpanda doesn't auto-deselect siblings when we set
|
|
640
|
+
// `option.selected`, so mirror what a real browser does and
|
|
641
|
+
// route the change through the parent's `value`.
|
|
642
|
+
sel.value = this.value;
|
|
643
|
+
}
|
|
644
|
+
sel.dispatchEvent(new Event('input', {bubbles: true}));
|
|
645
|
+
sel.dispatchEvent(new Event('change', {bubbles: true}));
|
|
646
|
+
}
|
|
647
|
+
JS
|
|
648
|
+
|
|
649
|
+
UNSELECT_OPTION_JS = <<~JS
|
|
650
|
+
function() {
|
|
651
|
+
var sel = this.parentElement;
|
|
652
|
+
while (sel && (sel.tagName || '').toUpperCase() !== 'SELECT') sel = sel.parentElement;
|
|
653
|
+
if (!sel || !sel.multiple) return;
|
|
654
|
+
this.selected = false;
|
|
655
|
+
sel.dispatchEvent(new Event('input', {bubbles: true}));
|
|
656
|
+
sel.dispatchEvent(new Event('change', {bubbles: true}));
|
|
657
|
+
}
|
|
658
|
+
JS
|
|
659
|
+
|
|
660
|
+
SET_CHECKBOX_JS = <<~JS
|
|
661
|
+
function(value) {
|
|
662
|
+
// Use `click()` so user-installed click/change handlers fire and
|
|
663
|
+
// observe a real toggle. No-op if already in the requested state.
|
|
664
|
+
if (this.checked !== value) this.click();
|
|
665
|
+
}
|
|
666
|
+
JS
|
|
667
|
+
|
|
668
|
+
APPEND_KEYS_JS = <<~JS
|
|
669
|
+
function(key) {
|
|
670
|
+
this.focus();
|
|
671
|
+
this.value += key;
|
|
672
|
+
this.dispatchEvent(new Event('input', {bubbles: true}));
|
|
673
|
+
}
|
|
674
|
+
JS
|
|
675
|
+
|
|
676
|
+
EDITABLE_HOST_JS = "function() { return _lightpanda.isContentEditable(this); }"
|
|
677
|
+
|
|
678
|
+
DISABLED_JS = "function() { return _lightpanda.isDisabled(this); }"
|
|
679
|
+
|
|
680
|
+
GET_STYLE_JS = <<~JS
|
|
681
|
+
function(prop) {
|
|
682
|
+
var win = this.ownerDocument.defaultView || window;
|
|
683
|
+
return win.getComputedStyle(this)[prop];
|
|
684
|
+
}
|
|
685
|
+
JS
|
|
686
|
+
|
|
687
|
+
GET_RECT_JS = <<~JS
|
|
688
|
+
function() {
|
|
689
|
+
var r = this.getBoundingClientRect();
|
|
690
|
+
return {
|
|
691
|
+
x: r.x, y: r.y,
|
|
692
|
+
top: r.top, bottom: r.bottom, left: r.left, right: r.right,
|
|
693
|
+
width: r.width, height: r.height
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
JS
|
|
697
|
+
|
|
698
|
+
OBSCURED_JS = "function() { return _lightpanda.isObscured(this); }"
|
|
699
|
+
|
|
700
|
+
GET_PATH_JS = <<~JS
|
|
701
|
+
function() {
|
|
702
|
+
var el = this;
|
|
703
|
+
var path = [];
|
|
704
|
+
while (el && el.nodeType === Node.ELEMENT_NODE) {
|
|
705
|
+
var selector = el.nodeName.toLowerCase();
|
|
706
|
+
if (el.id) {
|
|
707
|
+
selector += '#' + el.id;
|
|
708
|
+
path.unshift(selector);
|
|
709
|
+
break;
|
|
710
|
+
} else {
|
|
711
|
+
var sibling = el;
|
|
712
|
+
var nth = 1;
|
|
713
|
+
while (sibling = sibling.previousElementSibling) {
|
|
714
|
+
if (sibling.nodeName.toLowerCase() === el.nodeName.toLowerCase()) nth++;
|
|
715
|
+
}
|
|
716
|
+
if (nth > 1) selector += ':nth-of-type(' + nth + ')';
|
|
717
|
+
}
|
|
718
|
+
path.unshift(selector);
|
|
719
|
+
el = el.parentNode;
|
|
720
|
+
}
|
|
721
|
+
return path.join(' > ');
|
|
722
|
+
}
|
|
723
|
+
JS
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
end
|