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.
@@ -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