capybara-lightpanda 0.9.0 → 0.11.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 +51 -0
- data/README.md +14 -1
- data/lib/capybara/lightpanda/auto_scripts.rb +3 -1
- data/lib/capybara/lightpanda/binary.rb +70 -8
- data/lib/capybara/lightpanda/browser/console.rb +89 -7
- data/lib/capybara/lightpanda/browser/modals.rb +54 -3
- data/lib/capybara/lightpanda/browser/navigation.rb +1 -0
- data/lib/capybara/lightpanda/browser/selenium_compat.rb +124 -0
- data/lib/capybara/lightpanda/browser.rb +104 -4
- data/lib/capybara/lightpanda/downloads.rb +176 -0
- data/lib/capybara/lightpanda/driver.rb +109 -2
- data/lib/capybara/lightpanda/errors.rb +5 -0
- data/lib/capybara/lightpanda/javascripts/errors.js +74 -0
- data/lib/capybara/lightpanda/javascripts/predicates.js +19 -55
- data/lib/capybara/lightpanda/network.rb +36 -10
- data/lib/capybara/lightpanda/node.rb +357 -65
- data/lib/capybara/lightpanda/options.rb +53 -7
- data/lib/capybara/lightpanda/process.rb +173 -11
- data/lib/capybara/lightpanda/version.rb +1 -1
- data/lib/capybara-lightpanda.rb +1 -0
- metadata +5 -2
|
@@ -13,6 +13,22 @@ module Capybara
|
|
|
13
13
|
@remote_object_id = remote_object_id
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Capybara::Driver::Node#native returns the constructor's second argument,
|
|
17
|
+
# which here is the raw CDP objectId — a String. So the Selenium/Cuprite
|
|
18
|
+
# idiom `element.native.send_keys(...)` (solidus's
|
|
19
|
+
# return_authorizations_spec.rb does exactly that) died with
|
|
20
|
+
# "undefined method 'send_keys' for an instance of String". This driver
|
|
21
|
+
# has no lower-level node object behind the Capybara one — the CDP handle
|
|
22
|
+
# IS this Node (see #remote_object_id) — so `native` is self.
|
|
23
|
+
#
|
|
24
|
+
# Safe against Capybara::Driver::Node#==, which compares `native ==
|
|
25
|
+
# other.native` and would recurse forever on a self-returning `native`:
|
|
26
|
+
# #== and #eql? below are full overrides that never call super and never
|
|
27
|
+
# read #native.
|
|
28
|
+
def native
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
16
32
|
def text
|
|
17
33
|
call("function() { return this.textContent }")
|
|
18
34
|
end
|
|
@@ -21,10 +37,11 @@ module Capybara
|
|
|
21
37
|
filter_text(call("function() { return this.textContent }"))
|
|
22
38
|
end
|
|
23
39
|
|
|
24
|
-
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
40
|
+
# Delegates to _lightpanda.visibleText, which gates on visibility (a
|
|
41
|
+
# not-visible element reads as "" — WebDriver semantics) and otherwise
|
|
42
|
+
# hands the rendered-text collection (block line breaks + display:none
|
|
43
|
+
# descendant skipping) to native innerText (#2785/#2795). We normalize the
|
|
44
|
+
# whitespace here to match Capybara's expected Chrome semantics.
|
|
28
45
|
def visible_text
|
|
29
46
|
call(VISIBLE_TEXT_JS).to_s
|
|
30
47
|
.gsub(/\A[[:space:]&&[^\u00A0]]+/, "")
|
|
@@ -69,6 +86,17 @@ module Capybara
|
|
|
69
86
|
previous
|
|
70
87
|
end
|
|
71
88
|
|
|
89
|
+
# Quiet form of the `isConnected` guard every other operation carries:
|
|
90
|
+
# true while the node is still attached to a live document, false once
|
|
91
|
+
# it has been detached or its document navigated away (mirrors Ferrum's
|
|
92
|
+
# `Node#exists?`, whose probe is `DOM.resolveNode`). Anything else that
|
|
93
|
+
# goes wrong still raises — only "gone" is turned into false.
|
|
94
|
+
def exists?
|
|
95
|
+
call("function() { return true; }")
|
|
96
|
+
rescue ObsoleteNode, NodeNotFoundError, NoExecutionContextError
|
|
97
|
+
false
|
|
98
|
+
end
|
|
99
|
+
|
|
72
100
|
# Routed through #call (not a bare call_function_on) so a detached
|
|
73
101
|
# host raises ObsoleteNode like every other node operation — Capybara's
|
|
74
102
|
# automatic_reload then re-finds the host instead of silently reading
|
|
@@ -107,8 +135,15 @@ module Capybara
|
|
|
107
135
|
call("function() { this.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true})) }")
|
|
108
136
|
end
|
|
109
137
|
|
|
138
|
+
# A real pointer entering an element fires `mouseover` (bubbling) AND
|
|
139
|
+
# `mouseenter` (non-bubbling), in that order. Dispatching only `mouseover`
|
|
140
|
+
# silently no-ops the `mouseenter->menu#open` Stimulus idiom and the
|
|
141
|
+
# Floating UI / tippy-style menus built on it — the dominant hover-menu
|
|
142
|
+
# pattern in Rails apps — so fire both. CSS `:hover` still reveals nothing
|
|
143
|
+
# (upstream tracks no pointer state); test/features/hover_test.rb pins
|
|
144
|
+
# both halves.
|
|
110
145
|
def hover
|
|
111
|
-
call(
|
|
146
|
+
call(HOVER_JS)
|
|
112
147
|
end
|
|
113
148
|
|
|
114
149
|
# Kept as a deliberate no-op despite upstream now tracking scroll position
|
|
@@ -150,17 +185,64 @@ module Capybara
|
|
|
150
185
|
end
|
|
151
186
|
|
|
152
187
|
# Capybara's drag-and-drop API (`Element#drop`). String/Pathname arguments
|
|
153
|
-
# are file paths
|
|
154
|
-
#
|
|
155
|
-
#
|
|
156
|
-
#
|
|
188
|
+
# are file paths; Hash arguments are `{ mime_type => data }` string drops.
|
|
189
|
+
# We assemble a `DataTransfer` and fire `dragenter` -> `dragover` -> `drop`
|
|
190
|
+
# on this element, so HTML5 dropzones see the payload via
|
|
191
|
+
# `event.dataTransfer`.
|
|
192
|
+
#
|
|
193
|
+
# Files reach the page the way Cuprite's #316 does it: a hidden
|
|
194
|
+
# `<input type=file>` is attached to this element's document,
|
|
195
|
+
# `DOM.setFileInputFiles` points it at the paths (the browser reads the
|
|
196
|
+
# bytes off disk itself), and the drop JS moves `input.files` into the
|
|
197
|
+
# DataTransfer and removes the input. Previously the bytes were base64'd
|
|
198
|
+
# into the `Runtime.callFunctionOn` message, which capped a drop at
|
|
199
|
+
# ~70 MB under `--cdp-max-message-size` and pinned every byte in Ruby;
|
|
200
|
+
# now the size ceiling is Lightpanda's own file handling. Paths are read
|
|
201
|
+
# on the machine running Lightpanda (local for the spawned process),
|
|
202
|
+
# exactly like `attach_file`.
|
|
157
203
|
#
|
|
158
204
|
# DataTransfer/DataTransferItem/DragEvent landed upstream in PR #2671
|
|
159
205
|
# (build ≥6699) and are guaranteed by the MINIMUM_NIGHTLY_BUILD floor;
|
|
160
206
|
# without them the drop JS raises "DataTransfer is not defined".
|
|
161
207
|
def drop(*args)
|
|
162
|
-
|
|
163
|
-
|
|
208
|
+
paths, strings = partition_drop_args(args)
|
|
209
|
+
input = paths.empty? ? nil : attach_drop_input(paths)
|
|
210
|
+
call(DROP_JS, input, strings.to_json)
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Maps Capybara's documented drop_modifiers aliases onto the DragEvent
|
|
215
|
+
# init keys (`ctrlKey`, `metaKey`, ...). Same table as Cuprite's #315.
|
|
216
|
+
DRAG_MODIFIER_ALIASES = { control: :ctrl, command: :meta, cmd: :meta }.freeze
|
|
217
|
+
|
|
218
|
+
# Capybara's `Element#drag_to` — HTML5 half only. HTML5_DRAG_JS replays
|
|
219
|
+
# Capybara's own Selenium HTML5_DRAG_DROP_SCRIPT (the same source
|
|
220
|
+
# Cuprite's drag.js ports): dragstart on the draggable ancestor, then
|
|
221
|
+
# dragenter -> 2x dragover -> dragleave/drop -> dragend, setTimeout-paced,
|
|
222
|
+
# sharing one DataTransfer so `setData` in the page's dragstart handler is
|
|
223
|
+
# readable at drop. Runs through `evaluate_async` (the script signals
|
|
224
|
+
# completion via the appended callback), so the drag has fully played out
|
|
225
|
+
# before this method returns.
|
|
226
|
+
#
|
|
227
|
+
# The legacy path is coordinate-based mouse dragging, which Lightpanda
|
|
228
|
+
# cannot express (no layout to produce coordinates from) — it raises
|
|
229
|
+
# instead of silently no-oping. `html5: nil` auto-detects like Selenium
|
|
230
|
+
# does, via LEGACY_DRAG_CHECK_JS: we dispatch a synthetic mousedown where
|
|
231
|
+
# Selenium presses a real button, then apply the same
|
|
232
|
+
# prevented-or-no-draggable-ancestor test.
|
|
233
|
+
#
|
|
234
|
+
# `steps:`/`scroll:` (Cuprite's legacy-path knobs) are accepted and
|
|
235
|
+
# ignored so suites migrating from cuprite don't ArgumentError.
|
|
236
|
+
def drag_to(other, html5: nil, delay: 0.05, drop_modifiers: [], **)
|
|
237
|
+
keys = Array(drop_modifiers).map { |m| DRAG_MODIFIER_ALIASES.fetch(m.to_sym, m.to_sym).to_s }
|
|
238
|
+
html5 = !call(LEGACY_DRAG_CHECK_JS) if html5.nil?
|
|
239
|
+
unless html5
|
|
240
|
+
raise NotImplementedError,
|
|
241
|
+
"drag_to needs coordinate mouse dispatch for non-HTML5 (legacy) drags, which Lightpanda " \
|
|
242
|
+
"cannot do (no layout). Pass `html5: true` to force HTML5 DragEvent simulation."
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
driver.browser.evaluate_async(HTML5_DRAG_JS, self, other, (delay * 1000).to_i, keys)
|
|
164
246
|
nil
|
|
165
247
|
end
|
|
166
248
|
|
|
@@ -371,37 +453,26 @@ module Capybara
|
|
|
371
453
|
# here and base64-encoded so binary content survives the JSON hop; Hashes
|
|
372
454
|
# are `{ type => data }` string drops. Returns `[files, strings]`.
|
|
373
455
|
def partition_drop_args(args)
|
|
374
|
-
|
|
456
|
+
paths = []
|
|
375
457
|
strings = []
|
|
376
458
|
args.each do |arg|
|
|
377
459
|
if arg.is_a?(Hash)
|
|
378
460
|
arg.each { |type, data| strings << { type: type.to_s, data: data.to_s } }
|
|
379
461
|
else
|
|
380
|
-
|
|
381
|
-
files << {
|
|
382
|
-
name: File.basename(path),
|
|
383
|
-
type: drop_mime_for(path),
|
|
384
|
-
b64: [File.binread(path)].pack("m0"),
|
|
385
|
-
}
|
|
462
|
+
paths << File.expand_path(arg.to_s)
|
|
386
463
|
end
|
|
387
464
|
end
|
|
388
|
-
[
|
|
465
|
+
[paths, strings]
|
|
389
466
|
end
|
|
390
467
|
|
|
391
|
-
#
|
|
392
|
-
#
|
|
393
|
-
#
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
".zip" => "application/zip",
|
|
400
|
-
}.freeze
|
|
401
|
-
private_constant :DROP_MIME_TYPES
|
|
402
|
-
|
|
403
|
-
def drop_mime_for(path)
|
|
404
|
-
DROP_MIME_TYPES.fetch(File.extname(path).downcase, "application/octet-stream")
|
|
468
|
+
# Hidden `<input type=file multiple>` in this element's own document (so
|
|
469
|
+
# drops inside an iframe stay in that frame's DOM), pre-loaded via
|
|
470
|
+
# DOM.setFileInputFiles. Returned as a Node so it can be bound as a
|
|
471
|
+
# callFunctionOn argument; DROP_JS removes it once the files are moved.
|
|
472
|
+
def attach_drop_input(paths)
|
|
473
|
+
oid = call(CREATE_DROP_INPUT_JS, return_by_value: false)["objectId"]
|
|
474
|
+
driver.browser.set_file_input_files(oid, paths)
|
|
475
|
+
self.class.new(driver, oid)
|
|
405
476
|
end
|
|
406
477
|
|
|
407
478
|
# Whitespace-normalized text (Cuprite pattern). Capybara's text matchers compare
|
|
@@ -455,6 +526,18 @@ module Capybara
|
|
|
455
526
|
JS
|
|
456
527
|
end
|
|
457
528
|
|
|
529
|
+
# `mouseenter` carries `bubbles: false` per spec — it is dispatched on the
|
|
530
|
+
# target only, not walked up the ancestor chain the way a real pointer
|
|
531
|
+
# would. That covers the handler-on-the-hovered-element case (every
|
|
532
|
+
# Stimulus `mouseenter->` action) and stops short of emulating pointer
|
|
533
|
+
# geometry we don't have.
|
|
534
|
+
HOVER_JS = <<~JS
|
|
535
|
+
function() {
|
|
536
|
+
this.dispatchEvent(new MouseEvent('mouseover', {bubbles: true, cancelable: true}));
|
|
537
|
+
this.dispatchEvent(new MouseEvent('mouseenter', {bubbles: false, cancelable: true}));
|
|
538
|
+
}
|
|
539
|
+
JS
|
|
540
|
+
|
|
458
541
|
# We dispatch a `MouseEvent` (not a generic `Event`) because Turbo's link
|
|
459
542
|
# and form interceptors guard with `event instanceof MouseEvent` before
|
|
460
543
|
# they consider intercepting — a synthetic `Event('click')` is silently
|
|
@@ -473,25 +556,57 @@ module Capybara
|
|
|
473
556
|
CLICK_JS = <<~JS
|
|
474
557
|
function() {
|
|
475
558
|
var EventCtor = (typeof MouseEvent !== 'undefined') ? MouseEvent : Event;
|
|
559
|
+
// Emulate coordinate hit-testing without a layout engine. In a real
|
|
560
|
+
// browser a pointer click on a container lands on its frontmost
|
|
561
|
+
// descendant: THAT element is the event target, and the sequence
|
|
562
|
+
// bubbles UP from there (so the container's own handlers still fire).
|
|
563
|
+
// Lightpanda has no geometry — every rect is a hardcoded ~5x5, so
|
|
564
|
+
// document.elementFromPoint(center) just returns the container — so
|
|
565
|
+
// when `this` is a plain non-interactive wrapper we walk DOWN to the
|
|
566
|
+
// first visible child, repeatedly, stopping at the first element that
|
|
567
|
+
// is itself interactive or carries a click handler (onclick /
|
|
568
|
+
// role=button). That lands on the element a centered pointer would
|
|
569
|
+
// hit, so widgets that bind their handlers on an inner node fire:
|
|
570
|
+
// select2 v3 binds its open handler on the inner .select2-choice
|
|
571
|
+
// (single, mousedown) / .select2-choices (multi, click), the FIRST
|
|
572
|
+
// child of the .select2-container that Capybara helpers click. We
|
|
573
|
+
// can't use "single visible child" as a guard — select2 keeps an
|
|
574
|
+
// offscreen .select2-focusser <input> sibling that has no geometry to
|
|
575
|
+
// distinguish, so it reads as a second visible child. The descent
|
|
576
|
+
// never starts from an interactive element, so a normal
|
|
577
|
+
// button/link/input click still lands exactly on `this`.
|
|
578
|
+
var INTERACTIVE = { A: 1, BUTTON: 1, INPUT: 1, SELECT: 1, TEXTAREA: 1,
|
|
579
|
+
OPTION: 1, LABEL: 1, SUMMARY: 1 };
|
|
580
|
+
var hit = this;
|
|
581
|
+
while (!INTERACTIVE[hit.tagName] &&
|
|
582
|
+
!hit.hasAttribute('onclick') &&
|
|
583
|
+
hit.getAttribute('role') !== 'button') {
|
|
584
|
+
var next = null, ch = hit.children;
|
|
585
|
+
for (var i = 0; i < ch.length; i++) {
|
|
586
|
+
if (!window._lightpanda || _lightpanda.isVisible(ch[i])) { next = ch[i]; break; }
|
|
587
|
+
}
|
|
588
|
+
if (!next) break;
|
|
589
|
+
hit = next;
|
|
590
|
+
}
|
|
476
591
|
// Real pointer clicks are a mousedown -> mouseup -> click sequence,
|
|
477
592
|
// and widgets like select2 open on `mousedown`, not `click`.
|
|
478
593
|
// Cancelling mousedown suppresses focus/text-selection in a real
|
|
479
594
|
// browser but never the click, so the click below is dispatched
|
|
480
595
|
// unconditionally.
|
|
481
|
-
|
|
482
|
-
|
|
596
|
+
hit.dispatchEvent(new EventCtor('mousedown', { bubbles: true, cancelable: true }));
|
|
597
|
+
hit.dispatchEvent(new EventCtor('mouseup', { bubbles: true, cancelable: true }));
|
|
483
598
|
var clickEvt = new EventCtor('click', { bubbles: true, cancelable: true });
|
|
484
|
-
var notCancelled =
|
|
599
|
+
var notCancelled = hit.dispatchEvent(clickEvt);
|
|
485
600
|
if (!notCancelled || clickEvt.defaultPrevented) return;
|
|
486
|
-
var tag =
|
|
487
|
-
if (tag === 'A' &&
|
|
601
|
+
var tag = hit.tagName;
|
|
602
|
+
if (tag === 'A' && hit.href && hit.target !== '_blank') {
|
|
488
603
|
// Same-document fragment-only navigation: just update hash (or do
|
|
489
604
|
// nothing if identical). Mirrors Chrome — assigning location.href
|
|
490
605
|
// to a same-document URL on Lightpanda triggers a real navigation
|
|
491
606
|
// tick that cancels pending setTimeout callbacks and clears form
|
|
492
607
|
// values, which breaks any test driving DOM updates from a click
|
|
493
608
|
// handler on `<a href="#...">`.
|
|
494
|
-
var dest = new URL(
|
|
609
|
+
var dest = new URL(hit.href, document.baseURI);
|
|
495
610
|
var here = new URL(window.location.href);
|
|
496
611
|
if (dest.origin === here.origin && dest.pathname === here.pathname &&
|
|
497
612
|
dest.search === here.search) {
|
|
@@ -499,7 +614,7 @@ module Capybara
|
|
|
499
614
|
window.location.hash = dest.hash;
|
|
500
615
|
}
|
|
501
616
|
} else {
|
|
502
|
-
window.location.href =
|
|
617
|
+
window.location.href = hit.href;
|
|
503
618
|
}
|
|
504
619
|
}
|
|
505
620
|
}
|
|
@@ -532,17 +647,30 @@ module Capybara
|
|
|
532
647
|
}
|
|
533
648
|
JS
|
|
534
649
|
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
650
|
+
CREATE_DROP_INPUT_JS = <<~JS
|
|
651
|
+
function() {
|
|
652
|
+
var doc = this.ownerDocument || document;
|
|
653
|
+
var input = doc.createElement('input');
|
|
654
|
+
input.type = 'file';
|
|
655
|
+
input.multiple = true;
|
|
656
|
+
input.style.display = 'none';
|
|
657
|
+
input.setAttribute('data-lightpanda-drop-input', '');
|
|
658
|
+
(doc.body || doc.documentElement).appendChild(input);
|
|
659
|
+
return input;
|
|
660
|
+
}
|
|
661
|
+
JS
|
|
662
|
+
|
|
663
|
+
# Build a DataTransfer from the pre-loaded hidden input (files) and the
|
|
664
|
+
# JSON string payloads (typed items), then replay the HTML5 drop sequence
|
|
665
|
+
# on this element. The input is removed once its files are moved.
|
|
539
666
|
DROP_JS = <<~JS
|
|
540
|
-
function(
|
|
667
|
+
function(input, stringsJson) {
|
|
541
668
|
var el = this;
|
|
542
669
|
var dt = new DataTransfer();
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
670
|
+
if (input) {
|
|
671
|
+
for (var i = 0; i < input.files.length; i++) dt.items.add(input.files[i]);
|
|
672
|
+
input.remove();
|
|
673
|
+
}
|
|
546
674
|
JSON.parse(stringsJson).forEach(function(s) {
|
|
547
675
|
dt.items.add(s.data, s.type);
|
|
548
676
|
});
|
|
@@ -552,6 +680,151 @@ module Capybara
|
|
|
552
680
|
}
|
|
553
681
|
JS
|
|
554
682
|
|
|
683
|
+
# Selenium's MOUSEDOWN_TRACKER + LEGACY_DRAG_CHECK folded into one round
|
|
684
|
+
# trip. Selenium presses a real mouse button before checking; we dispatch
|
|
685
|
+
# a synthetic mousedown so drag libraries that preventDefault on it
|
|
686
|
+
# (mouse-based / fallback DnD) still steer the check toward the legacy
|
|
687
|
+
# path. Returns true when the drag would need the legacy (coordinate)
|
|
688
|
+
# path: mousedown prevented / never observed, or no draggable ancestor.
|
|
689
|
+
LEGACY_DRAG_CHECK_JS = <<~JS
|
|
690
|
+
function() {
|
|
691
|
+
var doc = this.ownerDocument || document;
|
|
692
|
+
var prevented = null;
|
|
693
|
+
doc.addEventListener('mousedown', function(ev) { prevented = ev.defaultPrevented; }, { once: true });
|
|
694
|
+
this.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
|
|
695
|
+
if (prevented === true || prevented === null) return true;
|
|
696
|
+
var el = this;
|
|
697
|
+
do {
|
|
698
|
+
if (el.draggable) return false;
|
|
699
|
+
} while ((el = el.parentElement));
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
JS
|
|
703
|
+
|
|
704
|
+
# Ported near-verbatim from Capybara's Selenium driver
|
|
705
|
+
# (capybara/selenium/extensions/html5_drag.rb, HTML5_DRAG_DROP_SCRIPT) —
|
|
706
|
+
# the same source Cuprite's #315 drag.js ports — kept close to ease
|
|
707
|
+
# future syncs. Upstream quirks preserved deliberately: `rectPt.top` in
|
|
708
|
+
# pointOnRect (DOMPoint has no .top, that branch just falls through), the
|
|
709
|
+
# undeclared `key` loop variable, and `callback.call(true)`. The
|
|
710
|
+
# `source.draggable` reads are native — the HTMLElement.draggable IDL
|
|
711
|
+
# shipped upstream in #3257 (build 8793, guaranteed by the floor; the
|
|
712
|
+
# `_lightpanda.isDraggable` polyfill was retired with that bump).
|
|
713
|
+
# Coordinates come from getBoundingClientRect, which Lightpanda
|
|
714
|
+
# synthesizes without layout — dropzones reading clientX/Y get
|
|
715
|
+
# plausible-but-synthetic (integer since #3259) numbers.
|
|
716
|
+
HTML5_DRAG_JS = <<~JS
|
|
717
|
+
function rectCenter(rect){
|
|
718
|
+
return new DOMPoint(
|
|
719
|
+
(rect.left + rect.right)/2,
|
|
720
|
+
(rect.top + rect.bottom)/2
|
|
721
|
+
);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function pointOnRect(pt, rect) {
|
|
725
|
+
var rectPt = rectCenter(rect);
|
|
726
|
+
var slope = (rectPt.y - pt.y) / (rectPt.x - pt.x);
|
|
727
|
+
|
|
728
|
+
if (pt.x <= rectPt.x) { // left side
|
|
729
|
+
var minXy = slope * (rect.left - pt.x) + pt.y;
|
|
730
|
+
if (rect.top <= minXy && minXy <= rect.bottom)
|
|
731
|
+
return new DOMPoint(rect.left, minXy);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (pt.x >= rectPt.x) { // right side
|
|
735
|
+
var maxXy = slope * (rect.right - pt.x) + pt.y;
|
|
736
|
+
if (rect.top <= maxXy && maxXy <= rect.bottom)
|
|
737
|
+
return new DOMPoint(rect.right, maxXy);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (pt.y <= rectPt.y) { // top side
|
|
741
|
+
var minYx = (rectPt.top - pt.y) / slope + pt.x;
|
|
742
|
+
if (rect.left <= minYx && minYx <= rect.right)
|
|
743
|
+
return new DOMPoint(minYx, rect.top);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (pt.y >= rectPt.y) { // bottom side
|
|
747
|
+
var maxYx = (rect.bottom - pt.y) / slope + pt.x;
|
|
748
|
+
if (rect.left <= maxYx && maxYx <= rect.right)
|
|
749
|
+
return new DOMPoint(maxYx, rect.bottom);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return new DOMPoint(pt.x,pt.y);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function dragEnterTarget() {
|
|
756
|
+
target.scrollIntoView({behavior: 'instant', block: 'center', inline: 'center'});
|
|
757
|
+
var targetRect = target.getBoundingClientRect();
|
|
758
|
+
var sourceCenter = rectCenter(source.getBoundingClientRect());
|
|
759
|
+
|
|
760
|
+
for (var i = 0; i < drop_modifier_keys.length; i++) {
|
|
761
|
+
key = drop_modifier_keys[i];
|
|
762
|
+
if (key == "control"){
|
|
763
|
+
key = "ctrl"
|
|
764
|
+
}
|
|
765
|
+
opts[key + 'Key'] = true;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
var dragEnterEvent = new DragEvent('dragenter', opts);
|
|
769
|
+
target.dispatchEvent(dragEnterEvent);
|
|
770
|
+
|
|
771
|
+
// fire 2 dragover events to simulate dragging with a direction
|
|
772
|
+
var entryPoint = pointOnRect(sourceCenter, targetRect)
|
|
773
|
+
var dragOverOpts = Object.assign({clientX: entryPoint.x, clientY: entryPoint.y}, opts);
|
|
774
|
+
var dragOverEvent = new DragEvent('dragover', dragOverOpts);
|
|
775
|
+
target.dispatchEvent(dragOverEvent);
|
|
776
|
+
window.setTimeout(dragOnTarget, step_delay);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function dragOnTarget() {
|
|
780
|
+
var targetCenter = rectCenter(target.getBoundingClientRect());
|
|
781
|
+
var dragOverOpts = Object.assign({clientX: targetCenter.x, clientY: targetCenter.y}, opts);
|
|
782
|
+
var dragOverEvent = new DragEvent('dragover', dragOverOpts);
|
|
783
|
+
target.dispatchEvent(dragOverEvent);
|
|
784
|
+
window.setTimeout(dragLeave, step_delay, dragOverEvent.defaultPrevented, dragOverOpts);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function dragLeave(drop, dragOverOpts) {
|
|
788
|
+
var dragLeaveOptions = Object.assign({}, opts, dragOverOpts);
|
|
789
|
+
var dragLeaveEvent = new DragEvent('dragleave', dragLeaveOptions);
|
|
790
|
+
target.dispatchEvent(dragLeaveEvent);
|
|
791
|
+
if (drop) {
|
|
792
|
+
var dropEvent = new DragEvent('drop', dragLeaveOptions);
|
|
793
|
+
target.dispatchEvent(dropEvent);
|
|
794
|
+
}
|
|
795
|
+
var dragEndEvent = new DragEvent('dragend', dragLeaveOptions);
|
|
796
|
+
source.dispatchEvent(dragEndEvent);
|
|
797
|
+
callback.call(true);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
var source = arguments[0],
|
|
801
|
+
target = arguments[1],
|
|
802
|
+
step_delay = arguments[2],
|
|
803
|
+
drop_modifier_keys = arguments[3],
|
|
804
|
+
callback = arguments[4];
|
|
805
|
+
|
|
806
|
+
var dt = new DataTransfer();
|
|
807
|
+
var opts = { cancelable: true, bubbles: true, dataTransfer: dt };
|
|
808
|
+
|
|
809
|
+
while (source && !source.draggable) {
|
|
810
|
+
source = source.parentElement;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
if (source.tagName == 'A'){
|
|
814
|
+
dt.setData('text/uri-list', source.href);
|
|
815
|
+
dt.setData('text', source.href);
|
|
816
|
+
}
|
|
817
|
+
if (source.tagName == 'IMG'){
|
|
818
|
+
dt.setData('text/uri-list', source.src);
|
|
819
|
+
dt.setData('text', source.src);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
var dragEvent = new DragEvent('dragstart', opts);
|
|
823
|
+
source.dispatchEvent(dragEvent);
|
|
824
|
+
|
|
825
|
+
window.setTimeout(dragEnterTarget, step_delay);
|
|
826
|
+
JS
|
|
827
|
+
|
|
555
828
|
VISIBLE_JS = "function() { return _lightpanda.isVisible(this); }"
|
|
556
829
|
|
|
557
830
|
VISIBLE_TEXT_JS = "function() { return _lightpanda.visibleText(this); }"
|
|
@@ -699,34 +972,53 @@ module Capybara
|
|
|
699
972
|
|
|
700
973
|
OBSCURED_JS = "function() { return _lightpanda.isObscured(this); }"
|
|
701
974
|
|
|
975
|
+
# Capybara's contract for Element#path is an XPath that re-finds the very
|
|
976
|
+
# same node (`node #path returns xpath which points to itself`), so this
|
|
977
|
+
# emits `/HTML/BODY/DIV[2]/P[1]` rather than the CSS-ish
|
|
978
|
+
# `html > body > div:nth-of-type(2) > p` it used to. Chrome exposes no
|
|
979
|
+
# native equivalent either; Cuprite hand-rolls the same walk.
|
|
980
|
+
#
|
|
981
|
+
# Every step is indexed, including single children: `P[1]` and `P` select
|
|
982
|
+
# the same node, but the explicit index keeps the output stable if a
|
|
983
|
+
# sibling appears later, and it is what Chrome DevTools' "Copy XPath"
|
|
984
|
+
# produces. Positions count same-tag preceding siblings, which is exactly
|
|
985
|
+
# XPath's own positional semantics.
|
|
986
|
+
#
|
|
987
|
+
# No `id`-based shortcut: an `//*[@id="x"]` prefix is shorter but stops
|
|
988
|
+
# pointing at *this* node the moment the document has a duplicate id,
|
|
989
|
+
# which malformed real-world pages routinely do.
|
|
990
|
+
#
|
|
991
|
+
# An element inside a shadow root has no document-level XPath at all
|
|
992
|
+
# (XPath doesn't cross shadow boundaries, and a `/SPAN[1]` computed from
|
|
993
|
+
# the shadow tree would re-find some unrelated light-DOM node). Selenium's
|
|
994
|
+
# driver returns the sentinel string below in that case and Capybara's
|
|
995
|
+
# shared `node #path` spec pins it, so mirror it rather than emit a path
|
|
996
|
+
# that lies.
|
|
702
997
|
GET_PATH_JS = <<~JS
|
|
703
998
|
function() {
|
|
704
999
|
var el = this;
|
|
705
|
-
|
|
1000
|
+
if (el.getRootNode && typeof ShadowRoot !== 'undefined' && el.getRootNode() instanceof ShadowRoot) {
|
|
1001
|
+
return '(: Shadow DOM element - no XPath :)';
|
|
1002
|
+
}
|
|
1003
|
+
var steps = [];
|
|
706
1004
|
while (el && el.nodeType === Node.ELEMENT_NODE) {
|
|
707
|
-
var
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
} else {
|
|
713
|
-
var sibling = el;
|
|
714
|
-
var nth = 1;
|
|
715
|
-
while (sibling = sibling.previousElementSibling) {
|
|
716
|
-
if (sibling.nodeName.toLowerCase() === el.nodeName.toLowerCase()) nth++;
|
|
717
|
-
}
|
|
718
|
-
if (nth > 1) selector += ':nth-of-type(' + nth + ')';
|
|
1005
|
+
var name = el.nodeName.toUpperCase();
|
|
1006
|
+
var index = 1;
|
|
1007
|
+
var sibling = el;
|
|
1008
|
+
while (sibling = sibling.previousElementSibling) {
|
|
1009
|
+
if (sibling.nodeName.toUpperCase() === name) index++;
|
|
719
1010
|
}
|
|
720
|
-
|
|
721
|
-
el = el.
|
|
1011
|
+
steps.unshift(name + '[' + index + ']');
|
|
1012
|
+
el = el.parentElement;
|
|
722
1013
|
}
|
|
723
|
-
return
|
|
1014
|
+
return '/' + steps.join('/');
|
|
724
1015
|
}
|
|
725
1016
|
JS
|
|
726
1017
|
|
|
727
1018
|
# Internal wire format, not API — aligned with browser.rb's convention
|
|
728
1019
|
# of private_constant for its JS snippets.
|
|
729
|
-
private_constant :CLICK_JS, :TRIGGER_JS, :DROP_JS, :
|
|
1020
|
+
private_constant :CLICK_JS, :TRIGGER_JS, :DROP_JS, :CREATE_DROP_INPUT_JS, :SHADOW_ROOT_JS, :VISIBLE_JS,
|
|
1021
|
+
:VISIBLE_TEXT_JS,
|
|
730
1022
|
:PROPERTY_OR_ATTRIBUTE_JS, :GET_VALUE_JS, :SET_VALUE_JS,
|
|
731
1023
|
:IMPLICIT_SUBMIT_JS, :SELECT_OPTION_JS, :UNSELECT_OPTION_JS,
|
|
732
1024
|
:SET_CHECKBOX_JS, :EDITABLE_HOST_JS, :DISABLED_JS, :GET_STYLE_JS,
|
|
@@ -20,14 +20,43 @@ module Capybara
|
|
|
20
20
|
# `Capybara::Lightpanda.configure { |c| c.port = 9222 }` when external
|
|
21
21
|
# tooling needs a known address.
|
|
22
22
|
DEFAULT_PORT = 0
|
|
23
|
-
|
|
23
|
+
# Mirrors Lightpanda's own `Viewport.default` (1920x1080) rather than
|
|
24
|
+
# Cuprite's 1024x768. window_size is applied for real now (see below), so
|
|
25
|
+
# a 1024x768 default would silently shrink the viewport of every existing
|
|
26
|
+
# suite and flip `@media` branches under them. Matching the browser's
|
|
27
|
+
# native default keeps `window_size` truthful AND leaves default
|
|
28
|
+
# behavior byte-identical to before it was wired up.
|
|
29
|
+
DEFAULT_WINDOW_SIZE = [1920, 1080].freeze
|
|
24
30
|
|
|
25
|
-
# window_size
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
31
|
+
# window_size drives Emulation.setDeviceMetricsOverride via
|
|
32
|
+
# Browser#set_viewport on every create_page. This is a JS-visible
|
|
33
|
+
# viewport only — it sets window.innerWidth/innerHeight and the viewport
|
|
34
|
+
# that `matchMedia` / `@media` evaluate against, so responsive branches
|
|
35
|
+
# resolve at the size you ask for. It is NOT real layout: Lightpanda has
|
|
36
|
+
# no rendering engine, so getBoundingClientRect stays synthetic and
|
|
37
|
+
# nothing reflows. Sizing down will not make an off-viewport element
|
|
38
|
+
# report as obscured.
|
|
39
|
+
# headless is accepted for Cuprite drop-in compatibility but inert —
|
|
40
|
+
# headless is the only mode Lightpanda runs in.
|
|
41
|
+
# save_path: directory for downloaded files (Cuprite parity). nil falls
|
|
42
|
+
# back to Capybara.save_path at create_page time; downloads stay off when
|
|
43
|
+
# both are nil (Browser#create_page only opts in when a path exists).
|
|
44
|
+
# raise_on_unhandled_modal: a JS dialog that opens with no
|
|
45
|
+
# accept_modal/dismiss_modal pre-arm in flight is resolved by Lightpanda's
|
|
46
|
+
# silent default (confirm → cancel, prompt → null, alert → dismissed), so
|
|
47
|
+
# a runaway confirm cancels the action and the spec can still pass. false
|
|
48
|
+
# (default, Cuprite parity) warns on stderr; true raises
|
|
49
|
+
# UnhandledModalError from the action that opened it.
|
|
50
|
+
# load_images: passes `--load-resources image` so `<img>` elements are
|
|
51
|
+
# fetched over the network (upstream #3230, build >= 8834). Off by
|
|
52
|
+
# default, matching Lightpanda's default of skipping image fetches
|
|
53
|
+
# entirely. When on, image requests ride the Network domain like any
|
|
54
|
+
# other — they show in `network.traffic` and count toward
|
|
55
|
+
# `wait_for_network_idle`. On builds < 8834 the flag is a fatal
|
|
56
|
+
# UnknownOption at boot, so only enable against binaries that have it.
|
|
29
57
|
attr_accessor :host, :port, :timeout, :handshake_timeout, :process_timeout,
|
|
30
|
-
:window_size, :browser_path, :headless, :logger
|
|
58
|
+
:window_size, :browser_path, :headless, :logger, :save_path,
|
|
59
|
+
:raise_on_unhandled_modal, :load_images
|
|
31
60
|
attr_writer :ws_url
|
|
32
61
|
|
|
33
62
|
def initialize(options = {})
|
|
@@ -36,9 +65,12 @@ module Capybara
|
|
|
36
65
|
@timeout = options.fetch(:timeout, DEFAULT_TIMEOUT)
|
|
37
66
|
@handshake_timeout = options.fetch(:handshake_timeout, DEFAULT_HANDSHAKE_TIMEOUT)
|
|
38
67
|
@process_timeout = options.fetch(:process_timeout, DEFAULT_PROCESS_TIMEOUT)
|
|
39
|
-
@window_size = options.fetch(:window_size, DEFAULT_WINDOW_SIZE)
|
|
68
|
+
@window_size = validate_window_size(options.fetch(:window_size, DEFAULT_WINDOW_SIZE))
|
|
40
69
|
@browser_path = options[:browser_path]
|
|
41
70
|
@headless = options.fetch(:headless, true)
|
|
71
|
+
@save_path = options[:save_path]
|
|
72
|
+
@raise_on_unhandled_modal = options.fetch(:raise_on_unhandled_modal, false)
|
|
73
|
+
@load_images = options.fetch(:load_images, false)
|
|
42
74
|
@ws_url = options[:ws_url]
|
|
43
75
|
@logger = parse_logger(options[:logger])
|
|
44
76
|
end
|
|
@@ -62,6 +94,9 @@ module Capybara
|
|
|
62
94
|
browser_path: browser_path,
|
|
63
95
|
headless: headless,
|
|
64
96
|
logger: logger,
|
|
97
|
+
save_path: save_path,
|
|
98
|
+
raise_on_unhandled_modal: raise_on_unhandled_modal,
|
|
99
|
+
load_images: load_images,
|
|
65
100
|
}
|
|
66
101
|
h[:ws_url] = @ws_url if @ws_url
|
|
67
102
|
h
|
|
@@ -69,6 +104,17 @@ module Capybara
|
|
|
69
104
|
|
|
70
105
|
private
|
|
71
106
|
|
|
107
|
+
# Validated here rather than at apply time so a bad value fails before
|
|
108
|
+
# Browser#initialize spawns a Lightpanda process — raising later would
|
|
109
|
+
# orphan it. Upstream reads a 0 dimension as "keep the current one", so
|
|
110
|
+
# forwarding junk would half-apply a viewport instead of failing.
|
|
111
|
+
def validate_window_size(size)
|
|
112
|
+
width, height = size
|
|
113
|
+
return size if width.is_a?(Integer) && height.is_a?(Integer) && width.positive? && height.positive?
|
|
114
|
+
|
|
115
|
+
raise ArgumentError, "window_size must be [width, height] of positive Integers, got #{size.inspect}"
|
|
116
|
+
end
|
|
117
|
+
|
|
72
118
|
def parse_logger(logger)
|
|
73
119
|
return logger if logger.is_a?(Capybara::Lightpanda::Logger)
|
|
74
120
|
return Capybara::Lightpanda::Logger.new(logger) if logger
|