capybara-lightpanda 0.8.0 → 0.10.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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -0
  3. data/README.md +14 -1
  4. data/lib/capybara/lightpanda/auto_scripts.rb +30 -2
  5. data/lib/capybara/lightpanda/binary.rb +72 -54
  6. data/lib/capybara/lightpanda/browser/console.rb +190 -0
  7. data/lib/capybara/lightpanda/browser/finder.rb +196 -0
  8. data/lib/capybara/lightpanda/browser/modals.rb +150 -0
  9. data/lib/capybara/lightpanda/browser/navigation.rb +186 -0
  10. data/lib/capybara/lightpanda/browser/runtime.rb +258 -0
  11. data/lib/capybara/lightpanda/browser/selenium_compat.rb +124 -0
  12. data/lib/capybara/lightpanda/browser.rb +158 -813
  13. data/lib/capybara/lightpanda/client/subscriber.rb +2 -0
  14. data/lib/capybara/lightpanda/client/web_socket.rb +19 -21
  15. data/lib/capybara/lightpanda/client.rb +5 -4
  16. data/lib/capybara/lightpanda/downloads.rb +176 -0
  17. data/lib/capybara/lightpanda/driver.rb +124 -32
  18. data/lib/capybara/lightpanda/errors.rb +24 -10
  19. data/lib/capybara/lightpanda/javascripts/attach.js +16 -0
  20. data/lib/capybara/lightpanda/javascripts/banner.js +15 -0
  21. data/lib/capybara/lightpanda/javascripts/errors.js +74 -0
  22. data/lib/capybara/lightpanda/javascripts/predicates.js +116 -0
  23. data/lib/capybara/lightpanda/javascripts/turbo.js +67 -0
  24. data/lib/capybara/lightpanda/keyboard.rb +23 -19
  25. data/lib/capybara/lightpanda/network.rb +103 -18
  26. data/lib/capybara/lightpanda/node.rb +200 -95
  27. data/lib/capybara/lightpanda/options.rb +44 -3
  28. data/lib/capybara/lightpanda/process.rb +154 -29
  29. data/lib/capybara/lightpanda/version.rb +1 -1
  30. data/lib/capybara-lightpanda.rb +1 -0
  31. metadata +14 -3
  32. data/lib/capybara/lightpanda/javascripts/index.js +0 -226
@@ -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,14 +86,23 @@ 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
+
100
+ # Routed through #call (not a bare call_function_on) so a detached
101
+ # host raises ObsoleteNode like every other node operation — Capybara's
102
+ # automatic_reload then re-finds the host instead of silently reading
103
+ # a stale shadowRoot.
72
104
  def shadow_root
73
- result = driver.browser.with_default_context_wait do
74
- driver.browser.call_function_on(
75
- @remote_object_id,
76
- "function() { return this.shadowRoot }",
77
- return_by_value: false
78
- )
79
- end
105
+ result = call(SHADOW_ROOT_JS, return_by_value: false)
80
106
  return nil unless result.is_a?(Hash) && result["objectId"]
81
107
 
82
108
  self.class.new(driver, result["objectId"])
@@ -109,8 +135,15 @@ module Capybara
109
135
  call("function() { this.dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true})) }")
110
136
  end
111
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.
112
145
  def hover
113
- call("function() { this.dispatchEvent(new MouseEvent('mouseover', {bubbles: true, cancelable: true})) }")
146
+ call(HOVER_JS)
114
147
  end
115
148
 
116
149
  # Kept as a deliberate no-op despite upstream now tracking scroll position
@@ -152,17 +185,29 @@ module Capybara
152
185
  end
153
186
 
154
187
  # Capybara's drag-and-drop API (`Element#drop`). String/Pathname arguments
155
- # are file paths — read here and rebuilt as `File` objects in the page;
156
- # Hash arguments are `{ mime_type => data }` string drops. We assemble a
157
- # `DataTransfer` and fire `dragenter` -> `dragover` -> `drop` on this
158
- # 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`.
159
203
  #
160
204
  # DataTransfer/DataTransferItem/DragEvent landed upstream in PR #2671
161
205
  # (build ≥6699) and are guaranteed by the MINIMUM_NIGHTLY_BUILD floor;
162
206
  # without them the drop JS raises "DataTransfer is not defined".
163
207
  def drop(*args)
164
- files, strings = partition_drop_args(args)
165
- 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)
166
211
  nil
167
212
  end
168
213
 
@@ -171,15 +216,9 @@ module Capybara
171
216
  end
172
217
 
173
218
  def unselect_option
174
- unless call("function() {
175
- var s = this.parentElement;
176
- while (s && (s.tagName || '').toUpperCase() !== 'SELECT') s = s.parentElement;
177
- return !!(s && s.multiple);
178
- }")
179
- raise Capybara::UnselectNotAllowed, "Cannot unselect option from single select box."
180
- end
219
+ return unless call(UNSELECT_OPTION_JS) == "not_multiple"
181
220
 
182
- call(UNSELECT_OPTION_JS)
221
+ raise Capybara::UnselectNotAllowed, "Cannot unselect option from single select box."
183
222
  end
184
223
 
185
224
  def send_keys(*)
@@ -379,37 +418,26 @@ module Capybara
379
418
  # here and base64-encoded so binary content survives the JSON hop; Hashes
380
419
  # are `{ type => data }` string drops. Returns `[files, strings]`.
381
420
  def partition_drop_args(args)
382
- files = []
421
+ paths = []
383
422
  strings = []
384
423
  args.each do |arg|
385
424
  if arg.is_a?(Hash)
386
425
  arg.each { |type, data| strings << { type: type.to_s, data: data.to_s } }
387
426
  else
388
- path = arg.to_s
389
- files << {
390
- name: File.basename(path),
391
- type: drop_mime_for(path),
392
- b64: [File.binread(path)].pack("m0"),
393
- }
427
+ paths << File.expand_path(arg.to_s)
394
428
  end
395
429
  end
396
- [files, strings]
430
+ [paths, strings]
397
431
  end
398
432
 
399
- # The dropzone handler only reads `file.name`, but real upload widgets key
400
- # off `file.type`, so map the common upload extensions and fall back to a
401
- # generic binary type.
402
- DROP_MIME_TYPES = {
403
- ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg", ".png" => "image/png",
404
- ".gif" => "image/gif", ".webp" => "image/webp", ".svg" => "image/svg+xml",
405
- ".pdf" => "application/pdf", ".txt" => "text/plain", ".csv" => "text/csv",
406
- ".json" => "application/json", ".html" => "text/html", ".xml" => "application/xml",
407
- ".zip" => "application/zip",
408
- }.freeze
409
- private_constant :DROP_MIME_TYPES
410
-
411
- def drop_mime_for(path)
412
- DROP_MIME_TYPES.fetch(File.extname(path).downcase, "application/octet-stream")
433
+ # Hidden `<input type=file multiple>` in this element's own document (so
434
+ # drops inside an iframe stay in that frame's DOM), pre-loaded via
435
+ # DOM.setFileInputFiles. Returned as a Node so it can be bound as a
436
+ # callFunctionOn argument; DROP_JS removes it once the files are moved.
437
+ def attach_drop_input(paths)
438
+ oid = call(CREATE_DROP_INPUT_JS, return_by_value: false)["objectId"]
439
+ driver.browser.set_file_input_files(oid, paths)
440
+ self.class.new(driver, oid)
413
441
  end
414
442
 
415
443
  # Whitespace-normalized text (Cuprite pattern). Capybara's text matchers compare
@@ -437,22 +465,18 @@ module Capybara
437
465
  # a DOM mutation like `replaceWith`, the cached objectId still resolves
438
466
  # to the detached node, so reads succeed quietly and Capybara's
439
467
  # automatic_reload never re-runs the original query.
440
- def call(function_declaration, *args)
468
+ def call(function_declaration, *args, return_by_value: true)
441
469
  guarded = wrap_with_attached_guard(function_declaration)
442
470
  driver.browser.with_default_context_wait do
443
- driver.browser.call_function_on(@remote_object_id, guarded, *args)
471
+ driver.browser.call_function_on(@remote_object_id, guarded, *args,
472
+ return_by_value: return_by_value)
444
473
  end
445
474
  rescue JavaScriptError => e
446
475
  if e.message.include?(OBSOLETE_NODE_MARKER)
447
476
  raise ObsoleteNode.new(self, "Node is no longer attached to the document")
448
477
  end
449
478
 
450
- case e.class_name
451
- when "InvalidSelector"
452
- raise InvalidSelector.new(e.message, nil, args.first)
453
- else
454
- raise
455
- end
479
+ raise
456
480
  end
457
481
 
458
482
  OBSOLETE_NODE_MARKER = "LIGHTPANDA_OBSOLETE_NODE"
@@ -467,6 +491,18 @@ module Capybara
467
491
  JS
468
492
  end
469
493
 
494
+ # `mouseenter` carries `bubbles: false` per spec — it is dispatched on the
495
+ # target only, not walked up the ancestor chain the way a real pointer
496
+ # would. That covers the handler-on-the-hovered-element case (every
497
+ # Stimulus `mouseenter->` action) and stops short of emulating pointer
498
+ # geometry we don't have.
499
+ HOVER_JS = <<~JS
500
+ function() {
501
+ this.dispatchEvent(new MouseEvent('mouseover', {bubbles: true, cancelable: true}));
502
+ this.dispatchEvent(new MouseEvent('mouseenter', {bubbles: false, cancelable: true}));
503
+ }
504
+ JS
505
+
470
506
  # We dispatch a `MouseEvent` (not a generic `Event`) because Turbo's link
471
507
  # and form interceptors guard with `event instanceof MouseEvent` before
472
508
  # they consider intercepting — a synthetic `Event('click')` is silently
@@ -485,25 +521,57 @@ module Capybara
485
521
  CLICK_JS = <<~JS
486
522
  function() {
487
523
  var EventCtor = (typeof MouseEvent !== 'undefined') ? MouseEvent : Event;
524
+ // Emulate coordinate hit-testing without a layout engine. In a real
525
+ // browser a pointer click on a container lands on its frontmost
526
+ // descendant: THAT element is the event target, and the sequence
527
+ // bubbles UP from there (so the container's own handlers still fire).
528
+ // Lightpanda has no geometry — every rect is a hardcoded ~5x5, so
529
+ // document.elementFromPoint(center) just returns the container — so
530
+ // when `this` is a plain non-interactive wrapper we walk DOWN to the
531
+ // first visible child, repeatedly, stopping at the first element that
532
+ // is itself interactive or carries a click handler (onclick /
533
+ // role=button). That lands on the element a centered pointer would
534
+ // hit, so widgets that bind their handlers on an inner node fire:
535
+ // select2 v3 binds its open handler on the inner .select2-choice
536
+ // (single, mousedown) / .select2-choices (multi, click), the FIRST
537
+ // child of the .select2-container that Capybara helpers click. We
538
+ // can't use "single visible child" as a guard — select2 keeps an
539
+ // offscreen .select2-focusser <input> sibling that has no geometry to
540
+ // distinguish, so it reads as a second visible child. The descent
541
+ // never starts from an interactive element, so a normal
542
+ // button/link/input click still lands exactly on `this`.
543
+ var INTERACTIVE = { A: 1, BUTTON: 1, INPUT: 1, SELECT: 1, TEXTAREA: 1,
544
+ OPTION: 1, LABEL: 1, SUMMARY: 1 };
545
+ var hit = this;
546
+ while (!INTERACTIVE[hit.tagName] &&
547
+ !hit.hasAttribute('onclick') &&
548
+ hit.getAttribute('role') !== 'button') {
549
+ var next = null, ch = hit.children;
550
+ for (var i = 0; i < ch.length; i++) {
551
+ if (!window._lightpanda || _lightpanda.isVisible(ch[i])) { next = ch[i]; break; }
552
+ }
553
+ if (!next) break;
554
+ hit = next;
555
+ }
488
556
  // Real pointer clicks are a mousedown -> mouseup -> click sequence,
489
557
  // and widgets like select2 open on `mousedown`, not `click`.
490
558
  // Cancelling mousedown suppresses focus/text-selection in a real
491
559
  // browser but never the click, so the click below is dispatched
492
560
  // unconditionally.
493
- this.dispatchEvent(new EventCtor('mousedown', { bubbles: true, cancelable: true }));
494
- this.dispatchEvent(new EventCtor('mouseup', { bubbles: true, cancelable: true }));
561
+ hit.dispatchEvent(new EventCtor('mousedown', { bubbles: true, cancelable: true }));
562
+ hit.dispatchEvent(new EventCtor('mouseup', { bubbles: true, cancelable: true }));
495
563
  var clickEvt = new EventCtor('click', { bubbles: true, cancelable: true });
496
- var notCancelled = this.dispatchEvent(clickEvt);
564
+ var notCancelled = hit.dispatchEvent(clickEvt);
497
565
  if (!notCancelled || clickEvt.defaultPrevented) return;
498
- var tag = this.tagName;
499
- if (tag === 'A' && this.href && this.target !== '_blank') {
566
+ var tag = hit.tagName;
567
+ if (tag === 'A' && hit.href && hit.target !== '_blank') {
500
568
  // Same-document fragment-only navigation: just update hash (or do
501
569
  // nothing if identical). Mirrors Chrome — assigning location.href
502
570
  // to a same-document URL on Lightpanda triggers a real navigation
503
571
  // tick that cancels pending setTimeout callbacks and clears form
504
572
  // values, which breaks any test driving DOM updates from a click
505
573
  // handler on `<a href="#...">`.
506
- var dest = new URL(this.href, document.baseURI);
574
+ var dest = new URL(hit.href, document.baseURI);
507
575
  var here = new URL(window.location.href);
508
576
  if (dest.origin === here.origin && dest.pathname === here.pathname &&
509
577
  dest.search === here.search) {
@@ -511,7 +579,7 @@ module Capybara
511
579
  window.location.hash = dest.hash;
512
580
  }
513
581
  } else {
514
- window.location.href = this.href;
582
+ window.location.href = hit.href;
515
583
  }
516
584
  }
517
585
  }
@@ -544,17 +612,30 @@ module Capybara
544
612
  }
545
613
  JS
546
614
 
547
- # Build a DataTransfer from the JSON payloads and replay the HTML5 drop
548
- # sequence on this element. Files arrive base64-encoded and are rebuilt
549
- # with `atob` (Blob accepts the binary string directly); string drops are
550
- # added as typed items so the page can read them via getData/getAsString.
615
+ CREATE_DROP_INPUT_JS = <<~JS
616
+ function() {
617
+ var doc = this.ownerDocument || document;
618
+ var input = doc.createElement('input');
619
+ input.type = 'file';
620
+ input.multiple = true;
621
+ input.style.display = 'none';
622
+ input.setAttribute('data-lightpanda-drop-input', '');
623
+ (doc.body || doc.documentElement).appendChild(input);
624
+ return input;
625
+ }
626
+ JS
627
+
628
+ # Build a DataTransfer from the pre-loaded hidden input (files) and the
629
+ # JSON string payloads (typed items), then replay the HTML5 drop sequence
630
+ # on this element. The input is removed once its files are moved.
551
631
  DROP_JS = <<~JS
552
- function(filesJson, stringsJson) {
632
+ function(input, stringsJson) {
553
633
  var el = this;
554
634
  var dt = new DataTransfer();
555
- JSON.parse(filesJson).forEach(function(f) {
556
- dt.items.add(new File([atob(f.b64)], f.name, { type: f.type }));
557
- });
635
+ if (input) {
636
+ for (var i = 0; i < input.files.length; i++) dt.items.add(input.files[i]);
637
+ input.remove();
638
+ }
558
639
  JSON.parse(stringsJson).forEach(function(s) {
559
640
  dt.items.add(s.data, s.type);
560
641
  });
@@ -665,11 +746,14 @@ module Capybara
665
746
  }
666
747
  JS
667
748
 
749
+ # Returns 'not_multiple' (without mutating) when the owning <select>
750
+ # isn't multiple — Ruby raises Capybara::UnselectNotAllowed. One CDP
751
+ # round-trip instead of a separate ancestor-walk precheck.
668
752
  UNSELECT_OPTION_JS = <<~JS
669
753
  function() {
670
754
  var sel = this.parentElement;
671
755
  while (sel && (sel.tagName || '').toUpperCase() !== 'SELECT') sel = sel.parentElement;
672
- if (!sel || !sel.multiple) return;
756
+ if (!sel || !sel.multiple) return 'not_multiple';
673
757
  this.selected = false;
674
758
  sel.dispatchEvent(new Event('input', {bubbles: true}));
675
759
  sel.dispatchEvent(new Event('change', {bubbles: true}));
@@ -682,13 +766,7 @@ module Capybara
682
766
  }
683
767
  JS
684
768
 
685
- APPEND_KEYS_JS = <<~JS
686
- function(key) {
687
- this.focus();
688
- this.value += key;
689
- this.dispatchEvent(new Event('input', {bubbles: true}));
690
- }
691
- JS
769
+ SHADOW_ROOT_JS = "function() { return this.shadowRoot }"
692
770
 
693
771
  EDITABLE_HOST_JS = "function() { return _lightpanda.isContentEditable(this); }"
694
772
 
@@ -714,30 +792,57 @@ module Capybara
714
792
 
715
793
  OBSCURED_JS = "function() { return _lightpanda.isObscured(this); }"
716
794
 
795
+ # Capybara's contract for Element#path is an XPath that re-finds the very
796
+ # same node (`node #path returns xpath which points to itself`), so this
797
+ # emits `/HTML/BODY/DIV[2]/P[1]` rather than the CSS-ish
798
+ # `html > body > div:nth-of-type(2) > p` it used to. Chrome exposes no
799
+ # native equivalent either; Cuprite hand-rolls the same walk.
800
+ #
801
+ # Every step is indexed, including single children: `P[1]` and `P` select
802
+ # the same node, but the explicit index keeps the output stable if a
803
+ # sibling appears later, and it is what Chrome DevTools' "Copy XPath"
804
+ # produces. Positions count same-tag preceding siblings, which is exactly
805
+ # XPath's own positional semantics.
806
+ #
807
+ # No `id`-based shortcut: an `//*[@id="x"]` prefix is shorter but stops
808
+ # pointing at *this* node the moment the document has a duplicate id,
809
+ # which malformed real-world pages routinely do.
810
+ #
811
+ # An element inside a shadow root has no document-level XPath at all
812
+ # (XPath doesn't cross shadow boundaries, and a `/SPAN[1]` computed from
813
+ # the shadow tree would re-find some unrelated light-DOM node). Selenium's
814
+ # driver returns the sentinel string below in that case and Capybara's
815
+ # shared `node #path` spec pins it, so mirror it rather than emit a path
816
+ # that lies.
717
817
  GET_PATH_JS = <<~JS
718
818
  function() {
719
819
  var el = this;
720
- var path = [];
820
+ if (el.getRootNode && typeof ShadowRoot !== 'undefined' && el.getRootNode() instanceof ShadowRoot) {
821
+ return '(: Shadow DOM element - no XPath :)';
822
+ }
823
+ var steps = [];
721
824
  while (el && el.nodeType === Node.ELEMENT_NODE) {
722
- var selector = el.nodeName.toLowerCase();
723
- if (el.id) {
724
- selector += '#' + el.id;
725
- path.unshift(selector);
726
- break;
727
- } else {
728
- var sibling = el;
729
- var nth = 1;
730
- while (sibling = sibling.previousElementSibling) {
731
- if (sibling.nodeName.toLowerCase() === el.nodeName.toLowerCase()) nth++;
732
- }
733
- if (nth > 1) selector += ':nth-of-type(' + nth + ')';
825
+ var name = el.nodeName.toUpperCase();
826
+ var index = 1;
827
+ var sibling = el;
828
+ while (sibling = sibling.previousElementSibling) {
829
+ if (sibling.nodeName.toUpperCase() === name) index++;
734
830
  }
735
- path.unshift(selector);
736
- el = el.parentNode;
831
+ steps.unshift(name + '[' + index + ']');
832
+ el = el.parentElement;
737
833
  }
738
- return path.join(' > ');
834
+ return '/' + steps.join('/');
739
835
  }
740
836
  JS
837
+
838
+ # Internal wire format, not API — aligned with browser.rb's convention
839
+ # of private_constant for its JS snippets.
840
+ private_constant :CLICK_JS, :TRIGGER_JS, :DROP_JS, :CREATE_DROP_INPUT_JS, :SHADOW_ROOT_JS, :VISIBLE_JS,
841
+ :VISIBLE_TEXT_JS,
842
+ :PROPERTY_OR_ATTRIBUTE_JS, :GET_VALUE_JS, :SET_VALUE_JS,
843
+ :IMPLICIT_SUBMIT_JS, :SELECT_OPTION_JS, :UNSELECT_OPTION_JS,
844
+ :SET_CHECKBOX_JS, :EDITABLE_HOST_JS, :DISABLED_JS, :GET_STYLE_JS,
845
+ :GET_RECT_JS, :OBSCURED_JS, :GET_PATH_JS
741
846
  end
742
847
  end
743
848
  end
@@ -20,10 +20,36 @@ 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
 
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.
25
50
  attr_accessor :host, :port, :timeout, :handshake_timeout, :process_timeout,
26
- :window_size, :browser_path, :headless, :logger
51
+ :window_size, :browser_path, :headless, :logger, :save_path,
52
+ :raise_on_unhandled_modal
27
53
  attr_writer :ws_url
28
54
 
29
55
  def initialize(options = {})
@@ -32,9 +58,11 @@ module Capybara
32
58
  @timeout = options.fetch(:timeout, DEFAULT_TIMEOUT)
33
59
  @handshake_timeout = options.fetch(:handshake_timeout, DEFAULT_HANDSHAKE_TIMEOUT)
34
60
  @process_timeout = options.fetch(:process_timeout, DEFAULT_PROCESS_TIMEOUT)
35
- @window_size = options.fetch(:window_size, DEFAULT_WINDOW_SIZE)
61
+ @window_size = validate_window_size(options.fetch(:window_size, DEFAULT_WINDOW_SIZE))
36
62
  @browser_path = options[:browser_path]
37
63
  @headless = options.fetch(:headless, true)
64
+ @save_path = options[:save_path]
65
+ @raise_on_unhandled_modal = options.fetch(:raise_on_unhandled_modal, false)
38
66
  @ws_url = options[:ws_url]
39
67
  @logger = parse_logger(options[:logger])
40
68
  end
@@ -58,6 +86,8 @@ module Capybara
58
86
  browser_path: browser_path,
59
87
  headless: headless,
60
88
  logger: logger,
89
+ save_path: save_path,
90
+ raise_on_unhandled_modal: raise_on_unhandled_modal,
61
91
  }
62
92
  h[:ws_url] = @ws_url if @ws_url
63
93
  h
@@ -65,6 +95,17 @@ module Capybara
65
95
 
66
96
  private
67
97
 
98
+ # Validated here rather than at apply time so a bad value fails before
99
+ # Browser#initialize spawns a Lightpanda process — raising later would
100
+ # orphan it. Upstream reads a 0 dimension as "keep the current one", so
101
+ # forwarding junk would half-apply a viewport instead of failing.
102
+ def validate_window_size(size)
103
+ width, height = size
104
+ return size if width.is_a?(Integer) && height.is_a?(Integer) && width.positive? && height.positive?
105
+
106
+ raise ArgumentError, "window_size must be [width, height] of positive Integers, got #{size.inspect}"
107
+ end
108
+
68
109
  def parse_logger(logger)
69
110
  return logger if logger.is_a?(Capybara::Lightpanda::Logger)
70
111
  return Capybara::Lightpanda::Logger.new(logger) if logger