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.
@@ -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
- # Lightpanda's innerText returns textContent verbatim (no rendering, so no
25
- # hidden-descendant filtering). Walk descendants ourselves, skipping nodes
26
- # that fail VISIBLE_JS, and emit newlines around block-display elements
27
- # (the part of innerText behavior we still need).
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("function() { this.dispatchEvent(new MouseEvent('mouseover', {bubbles: true, cancelable: true})) }")
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 — read here and rebuilt as `File` objects in the page;
154
- # Hash arguments are `{ mime_type => data }` string drops. We assemble a
155
- # `DataTransfer` and fire `dragenter` -> `dragover` -> `drop` on this
156
- # element, so HTML5 dropzones see the payload via `event.dataTransfer`.
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
- files, strings = partition_drop_args(args)
163
- call(DROP_JS, files.to_json, strings.to_json)
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
- files = []
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
- path = arg.to_s
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
- [files, strings]
465
+ [paths, strings]
389
466
  end
390
467
 
391
- # The dropzone handler only reads `file.name`, but real upload widgets key
392
- # off `file.type`, so map the common upload extensions and fall back to a
393
- # generic binary type.
394
- DROP_MIME_TYPES = {
395
- ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg", ".png" => "image/png",
396
- ".gif" => "image/gif", ".webp" => "image/webp", ".svg" => "image/svg+xml",
397
- ".pdf" => "application/pdf", ".txt" => "text/plain", ".csv" => "text/csv",
398
- ".json" => "application/json", ".html" => "text/html", ".xml" => "application/xml",
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
- this.dispatchEvent(new EventCtor('mousedown', { bubbles: true, cancelable: true }));
482
- this.dispatchEvent(new EventCtor('mouseup', { bubbles: true, cancelable: true }));
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 = this.dispatchEvent(clickEvt);
599
+ var notCancelled = hit.dispatchEvent(clickEvt);
485
600
  if (!notCancelled || clickEvt.defaultPrevented) return;
486
- var tag = this.tagName;
487
- if (tag === 'A' && this.href && this.target !== '_blank') {
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(this.href, document.baseURI);
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 = this.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
- # Build a DataTransfer from the JSON payloads and replay the HTML5 drop
536
- # sequence on this element. Files arrive base64-encoded and are rebuilt
537
- # with `atob` (Blob accepts the binary string directly); string drops are
538
- # added as typed items so the page can read them via getData/getAsString.
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(filesJson, stringsJson) {
667
+ function(input, stringsJson) {
541
668
  var el = this;
542
669
  var dt = new DataTransfer();
543
- JSON.parse(filesJson).forEach(function(f) {
544
- dt.items.add(new File([atob(f.b64)], f.name, { type: f.type }));
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
- var path = [];
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 selector = el.nodeName.toLowerCase();
708
- if (el.id) {
709
- selector += '#' + el.id;
710
- path.unshift(selector);
711
- break;
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
- path.unshift(selector);
721
- el = el.parentNode;
1011
+ steps.unshift(name + '[' + index + ']');
1012
+ el = el.parentElement;
722
1013
  }
723
- return path.join(' > ');
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, :SHADOW_ROOT_JS, :VISIBLE_JS, :VISIBLE_TEXT_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
- DEFAULT_WINDOW_SIZE = [1024, 768].freeze
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 and headless are accepted for Cuprite drop-in
26
- # compatibility (standard options at driver registration) but are
27
- # inert: Lightpanda has no rendering engine, so there is nothing to
28
- # resize, and headless is the only mode it runs in.
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