capybara-lightpanda 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 11c82a5cc9cde03ae1a231efb72764a35c77beda531118960fb499c209348a3e
4
- data.tar.gz: 66f4b3190f2dcc158925fd26e7b7b0590a8460d9789737d8bb08c3d5f9deed6b
3
+ metadata.gz: 6b898fd26a9e25f95263b605f022264cd2a132bbca82081bd931fe542c88673e
4
+ data.tar.gz: 4776e1326f56cd6f123538b130dfb7de1edb1a745a45574e31520a1a5227ce94
5
5
  SHA512:
6
- metadata.gz: 1dc37a8e9df8fb0533718e9aa17f35cb7dc162251a0f5f46e036b054afcbeec2da928277ac3af60ac592493d80357913f1f8d2384d671baa2e802905b6357c09
7
- data.tar.gz: 95c360b046406da232e6104fe9cb10aa158fedc2ff4e3cdeaa7c12042bc3bd725d9825bd8e2c730365e46c0f9f884c02c4a523af1ce9e4d582fc5aa760093e99
6
+ metadata.gz: 025d4fd23a6f0b4379c6e34d878610b657a06dcf33ab5490b606e5e42ed2e452475860283df232f8c397bbf1b48144f0d89be33bf7e5a70fe430a14e26bfb1a7
7
+ data.tar.gz: 16193011f9ea880c74300a45491ab5dbe36abe55e0a080fe1f65edf36d9722daf6e9842de361f498068952970f3f2d975a64d8b92eb8ecaf2600c01b342caf34
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.11.0] - 2026-08-25
4
+
5
+ > **Update Lightpanda before upgrading.** This release requires a Lightpanda nightly build ≥ 8796 (2026-08-25 or newer). **No tagged Lightpanda release is new enough yet**: 0.3.7 is below the floor and is now refused, so version pinning is unavailable until Lightpanda ships its next release — CI runs on the rolling nightly in the meantime.
6
+
7
+ ### Added
8
+
9
+ - **Drag and drop between elements.** `element.drag_to(target)` performs an HTML5 drag — `dragstart` through `drop` with a shared `DataTransfer`, so dropzone pages and `setData`/`getData` handlers behave as in Chrome, and drag events carry whole-number coordinates like Chrome's. Libraries that only respond to real mouse movement (SortableJS's fallback mode) remain out of reach — there is still no layout engine to drive a pointer through.
10
+ - **`load_images` driver option.** `load_images: true` makes the browser fetch `<img>` sources over the network, so image requests appear in `network.traffic` and are counted by `wait_for_network_idle` — useful for suites that assert on image traffic. Off by default (images are skipped entirely, as before). Needs a nightly from 2026-08-26 onward.
11
+ - **Screenshots show the page.** `save_screenshot` — and the automatic failure screenshots capybara-screenshot takes — now produce a real rendering of the page's content instead of a fixed placeholder image. It is a text-layout rendering, useful for seeing what the DOM held at failure time; pixel-accurate visual regression still needs a full browser.
12
+
13
+ ### Removed
14
+
15
+ - The internal workaround for reading an element's `draggable` state is gone now that the browser answers natively — no change required on your end.
16
+
3
17
  ## [0.10.0] - 2026-08-19
4
18
 
5
19
  > **Update Lightpanda before upgrading.** This release requires a Lightpanda nightly build ≥ 8448 **or a tagged release ≥ 0.3.7**. The driver refuses to start below either floor and tells you which version it found. If you pin the browser (see below), pin `0.3.7` or newer.
data/README.md CHANGED
@@ -57,7 +57,7 @@ driven_by :lightpanda
57
57
  > Lightpanda is a headless agentic browser, not a layout engine. External `<link rel="stylesheet">` **are** fetched and applied (the gem enables this by default), and `@media` / `window.matchMedia()` evaluate against the `window_size` you configure — so a mobile-only CTA gated by `@media (max-width: …)` resolves at the width you ask for. What's missing is *layout*: nothing reflows, `getBoundingClientRect` stays synthetic, and there is no real scroll. Specs that assert on pixel geometry, scrolling, or screenshots — plus the two that catch people out, a second browser tab and a menu revealed purely by CSS `:hover` — should stay on Cuprite (or whichever full-browser driver you were already using). The [per-spec dual-driver setup](https://navidemad.github.io/capybara-lightpanda/docs/#dual-per-spec) routes that minority to Cuprite and the structural majority to Lightpanda for speed.
58
58
 
59
59
  > [!TIP]
60
- > For reproducible CI, pin the browser: `Capybara::Lightpanda::Binary.required_version = "0.3.7"`. Without a pin the driver tracks Lightpanda's rolling `nightly` tag, which moves under you. See [Pinning the browser version](https://navidemad.github.io/capybara-lightpanda/docs/#pinning).
60
+ > For reproducible CI, pin the browser: `Capybara::Lightpanda::Binary.required_version = "0.3.8"`. (0.3.8 is the first release that will clear the gem's current floor, nightly build 8796 — until upstream tags it, the driver needs the rolling `nightly` tag.) Without a pin the driver tracks Lightpanda's rolling `nightly` tag, which moves under you. See [Pinning the browser version](https://navidemad.github.io/capybara-lightpanda/docs/#pinning).
61
61
 
62
62
  ## Credits
63
63
 
@@ -211,6 +211,41 @@ module Capybara
211
211
  nil
212
212
  end
213
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)
246
+ nil
247
+ end
248
+
214
249
  def select_option
215
250
  call(SELECT_OPTION_JS)
216
251
  end
@@ -645,6 +680,151 @@ module Capybara
645
680
  }
646
681
  JS
647
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
+
648
828
  VISIBLE_JS = "function() { return _lightpanda.isVisible(this); }"
649
829
 
650
830
  VISIBLE_TEXT_JS = "function() { return _lightpanda.visibleText(this); }"
@@ -47,9 +47,16 @@ module Capybara
47
47
  # a runaway confirm cancels the action and the spec can still pass. false
48
48
  # (default, Cuprite parity) warns on stderr; true raises
49
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.
50
57
  attr_accessor :host, :port, :timeout, :handshake_timeout, :process_timeout,
51
58
  :window_size, :browser_path, :headless, :logger, :save_path,
52
- :raise_on_unhandled_modal
59
+ :raise_on_unhandled_modal, :load_images
53
60
  attr_writer :ws_url
54
61
 
55
62
  def initialize(options = {})
@@ -63,6 +70,7 @@ module Capybara
63
70
  @headless = options.fetch(:headless, true)
64
71
  @save_path = options[:save_path]
65
72
  @raise_on_unhandled_modal = options.fetch(:raise_on_unhandled_modal, false)
73
+ @load_images = options.fetch(:load_images, false)
66
74
  @ws_url = options[:ws_url]
67
75
  @logger = parse_logger(options[:logger])
68
76
  end
@@ -88,6 +96,7 @@ module Capybara
88
96
  logger: logger,
89
97
  save_path: save_path,
90
98
  raise_on_unhandled_modal: raise_on_unhandled_modal,
99
+ load_images: load_images,
91
100
  }
92
101
  h[:ws_url] = @ws_url if @ws_url
93
102
  h
@@ -180,7 +180,8 @@ module Capybara
180
180
  # set, merged 2026-07-30) — a use-after-free in exactly the path
181
181
  # `Node#select_option`/`Node#set` on a <select> drives, i.e. a browser
182
182
  # crash surfacing as DeadBrowserError mid-spec.
183
- # Build 8448 = the #3087 merge (6d824c88) — now the binding floor.
183
+ # Build 8448 = the #3087 merge (6d824c88) — the binding floor of the
184
+ # 2026-07-30 bump.
184
185
  # Subsumed by 8448, recorded so they are not re-derived: build 8298 made
185
186
  # Network.enable idempotent (the gem never hit it — the Notification is
186
187
  # per-BrowserContext and Network#enable's @enabled guard means one enable
@@ -191,7 +192,16 @@ module Capybara
191
192
  # `window_size` option. Note the override only reached
192
193
  # Page.getLayoutMetrics later (~build 8300); the gem does not depend on
193
194
  # that half, so it is NOT part of the floor.
194
- MINIMUM_NIGHTLY_BUILD = Gem::Version.new("8448")
195
+ #
196
+ # 2026-08-25 bump 8448 -> 8796: #3257 (HTMLElement.draggable IDL, build
197
+ # 8793) and #3259 (MouseEvent coordinate getters floor to integers
198
+ # Chrome-style, build 8796; PointerEvent stays fractional). The floor
199
+ # guarantees native `.draggable`, so the drag scripts in node.rb read it
200
+ # verbatim — the `_lightpanda.isDraggable` polyfill was retired with
201
+ # this bump — and drag events carry integer clientX/Y (the shared spec
202
+ # asserting that runs un-skipped). Build 8796 = the #3259 merge
203
+ # (341a01570), which is also the 2026-08-25 nightly cut.
204
+ MINIMUM_NIGHTLY_BUILD = Gem::Version.new("8796")
195
205
 
196
206
  # Second, equivalent floor for the *release* channel.
197
207
  #
@@ -200,15 +210,21 @@ module Capybara
200
210
  # bare "0.3.6" carrying no commit counter at all, and MINIMUM_NIGHTLY_BUILD
201
211
  # has nothing to compare against. Releases are cut from the same trunk, so
202
212
  # a release is acceptable exactly when its own commit count clears the
203
- # nightly floor: 0.3.7 is build 8671, well past the #3087 merge (8448)
204
- # this floor exists for. (0.3.6 is only 8318 — it predates the optgroup
205
- # fix and everything after it, which is why this pin had to move.)
213
+ # nightly floor. As of 2026-08-25 NO published release clears the 8796
214
+ # floor 0.3.7 (build 8671) predates the draggable-IDL and
215
+ # coordinate-flooring fixes the floor exists for so this pin names the
216
+ # NEXT release. 0.3.8 is not tagged yet; any future tag is cut from a
217
+ # trunk already past build 8834, so it clears the floor by construction.
218
+ # Until upstream tags it, the release channel is deliberately unusable
219
+ # (nightly-only), reported with the standard too-old error. When 0.3.8
220
+ # ships: verify `git rev-list --count 0.3.8` >= 8796 and update the
221
+ # lockstep table in process_test.rb.
206
222
  #
207
223
  # INVARIANT: every MINIMUM_NIGHTLY_BUILD bump must also move this to the
208
224
  # first release containing that build (`git rev-list --count <tag>` in the
209
225
  # browser repo tells you). Leaving it behind would let the release channel
210
226
  # silently accept a binary the nightly channel rejects.
211
- MINIMUM_RELEASE = Gem::Version.new("0.3.7")
227
+ MINIMUM_RELEASE = Gem::Version.new("0.3.8")
212
228
 
213
229
  attr_reader :pid, :ws_url, :version, :nightly_build, :release
214
230
 
@@ -371,6 +387,12 @@ module Capybara
371
387
  "--cdp-max-message-size",
372
388
  (100 * 1024 * 1024).to_s,
373
389
  ]
390
+ # Opt-in image fetching (upstream #3230, build >= 8834). Deliberately
391
+ # NOT always-on like --enable-external-stylesheets: images never feed
392
+ # the DOM predicates, so the default spends no bandwidth on them. On
393
+ # builds < 8834 the flag is a fatal UnknownOption at boot — see
394
+ # Options#load_images.
395
+ base.push("--load-resources", "image") if @options.load_images
374
396
  extra = ENV.fetch("LIGHTPANDA_EXTRA_ARGS", "").split
375
397
  base + extra
376
398
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Lightpanda
5
- VERSION = "0.10.0"
5
+ VERSION = "0.11.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: capybara-lightpanda
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Navid Emad