capybara-lightpanda 0.10.0 → 0.12.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: 2515195575d1146f7cd14f479801360770c65d39d84c2bdc58be987e503bda88
4
+ data.tar.gz: ab94aba998ccafb870e109504e7f33dd22a35bcd2ba79750cfd2bb79226feaf1
5
5
  SHA512:
6
- metadata.gz: 1dc37a8e9df8fb0533718e9aa17f35cb7dc162251a0f5f46e036b054afcbeec2da928277ac3af60ac592493d80357913f1f8d2384d671baa2e802905b6357c09
7
- data.tar.gz: 95c360b046406da232e6104fe9cb10aa158fedc2ff4e3cdeaa7c12042bc3bd725d9825bd8e2c730365e46c0f9f884c02c4a523af1ce9e4d582fc5aa760093e99
6
+ metadata.gz: 3eadd479f7c543354a89c3f69a1be6ee94be6067b52171766c3da1b3c9b091a9e404c987e9af11c5230c89e8df319013822748b0bd6ba0e7e56ebd143f3e7689
7
+ data.tar.gz: fec1bbad25a59b177b6ac117e82bf65eef995196f233aa96742f71b2def90133132b913c0591bf40f2ca554d09cb4e6b26cbb132a0f3ae5e7ab811efa4504914
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.12.0] - 2026-08-28
4
+
5
+ > **Update Lightpanda before upgrading.** This release requires a Lightpanda nightly build ≥ 8875, and the rolling `nightly` tag now carries it (8982 as of 2026-08-28), so updating the browser is all it takes. **No tagged Lightpanda release is new enough yet**: 0.3.7 is below the floor, so version pinning stays unavailable until Lightpanda ships its next release.
6
+
7
+ ### Added
8
+
9
+ - **The keyboard activates things.** Pressing Enter on a button, link or submit input — and Space on a button, checkbox or radio — now does what it does in a real browser: the control activates, forms submit, checkboxes toggle. Previously `send_keys(:enter)` and `send_keys(:space)` were silently ignored on those controls, so keyboard-accessibility specs passed by doing nothing. Printable keys also emit `keypress` now, and a handler that cancels `keypress` correctly suppresses Enter's implicit form submission.
10
+ - **A closed `<dialog>` is properly hidden.** Text and controls inside a `<dialog>` that has never been opened no longer match Capybara's default visibility filter. Suites using the `<dialog>`-as-confirmation idiom (Turbo, Spree 5's admin) could previously "find" and click a confirm button that was not on screen — passing where Chrome would have raised `ElementNotFound`.
11
+ - **Uppercase inline styles hide elements.** `style="display: NONE"` and `style="visibility: HIDDEN"` are now treated as hidden, matching the CSS spec's case-insensitive keywords. Legacy templates and CMS output that upcase keywords no longer read as visible.
12
+ - **`ws_url:` accepts `localhost`.** Connecting to an externally-managed Lightpanda over `ws://localhost:PORT/` works; previously only an IP literal such as `ws://127.0.0.1:PORT/` was accepted by the browser's handshake.
13
+
14
+ ### Fixed
15
+
16
+ - **Keyboard-triggered navigation is waited for.** Now that Enter activates a submit button, pressing it starts a real navigation — but `send_keys` returned without waiting for it, so the very next `current_url` (or any non-waiting assertion) could still report the previous page. Keystrokes now settle the page the same way a click does. As with a click, a navigation whose server round-trip is slow still needs one of Capybara's waiting matchers, such as `have_current_path`.
17
+
18
+ - **Iframes keep working when the browser stops loading them by default.** Lightpanda build 8946 turns off `<iframe>` and Web Worker loading unless asked: the parser still puts the iframe in the DOM, but no frame is ever created, so `within_frame` and `switch_to_frame` find nothing and report no cause. The driver now asks for both on every session, which holds today's behavior across the change and needs no newer browser — the request is understood by every build the gem supports. Nothing to configure. If you run the rolling nightly, take this before your browser passes 8946.
19
+
20
+ - **A browser you manage yourself is version-checked like one the gem spawns.** `ws_url:` was the only way into the driver that skipped the version floor: a Lightpanda too old for the gem connected happily, then failed further along as a CDP error naming neither the version nor the option that let it through. The driver now reads the version over CDP at connect and refuses anything below the floor, with the same message the spawn path produces. An endpoint that cannot report a version at all — a Lightpanda older than the command, or a Chrome pointed at by mistake — is refused for the same reason. `page.driver.browser.version`, `.nightly_build` and `.release` report the browser on this path now instead of returning `nil`.
21
+
22
+ ## [0.11.0] - 2026-08-25
23
+
24
+ > **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.
25
+
26
+ ### Added
27
+
28
+ - **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.
29
+ - **`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.
30
+ - **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.
31
+
32
+ ### Removed
33
+
34
+ - The internal workaround for reading an element's `draggable` state is gone now that the browser answers natively — no change required on your end.
35
+
3
36
  ## [0.10.0] - 2026-08-19
4
37
 
5
38
  > **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 8875 — 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
 
@@ -73,28 +73,34 @@ module Capybara
73
73
  end
74
74
  end
75
75
 
76
- # Lightpanda binary version (e.g. "lightpanda 0.2.9 nightly.5267") and
77
- # parsed nightly build number, captured at Process startup. nil when
78
- # the gem is connecting to an externally-managed Lightpanda via ws_url.
76
+ # Lightpanda version string (e.g. "1.0.0-nightly.8925+a7bda0ea5") and its
77
+ # parsed build number. Learned from `lightpanda version` at Process
78
+ # startup on the spawn path, and from the CDP `LP.version` command when
79
+ # connecting to an externally-managed browser via ws_url — so these are
80
+ # populated either way, and a caller inspecting them does not have to
81
+ # know which path built the session.
79
82
  def version
80
- @process&.version
83
+ @process&.version || @remote_version
81
84
  end
82
85
 
83
86
  # Set on the nightly/dev channel only; nil for a tagged release, which
84
87
  # carries no build counter. `release` is its mirror image — exactly one of
85
- # the two is non-nil once the process has started.
88
+ # the two is non-nil once the session is up.
86
89
  def nightly_build
87
- @process&.nightly_build
90
+ @process&.nightly_build || @remote_nightly_build
88
91
  end
89
92
 
90
93
  def release
91
- @process&.release
94
+ @process&.release || @remote_release
92
95
  end
93
96
 
94
97
  def initialize(options = {})
95
98
  @options = Options.new(options)
96
99
  @process = nil
97
100
  @client = nil
101
+ @remote_version = nil
102
+ @remote_nightly_build = nil
103
+ @remote_release = nil
98
104
  @target_id = nil
99
105
  @session_id = nil
100
106
  @browser_context_id = nil
@@ -121,6 +127,7 @@ module Capybara
121
127
 
122
128
  if @options.ws_url?
123
129
  @client = Client.new(@options.ws_url, @options)
130
+ check_remote_version
124
131
  else
125
132
  @process = Process.new(@options)
126
133
  @process.start
@@ -152,6 +159,9 @@ module Capybara
152
159
  attach_result = @client.command("Target.attachToTarget", { targetId: @target_id, flatten: true })
153
160
  @session_id = attach_result["sessionId"]
154
161
 
162
+ # Must precede the first navigation: sub-resource loading is a
163
+ # per-BrowserContext setting read while the document loads.
164
+ configure_loading
155
165
  @turbo_event.set
156
166
  subscribe_to_console_logs
157
167
  subscribe_to_console_capture
@@ -498,6 +508,34 @@ module Capybara
498
508
 
499
509
  private
500
510
 
511
+ # Keep iframes and workers loading.
512
+ #
513
+ # Upstream #3305 (build 8946) turned `Config.LoadResources` into a
514
+ # four-field struct defaulting every sub-resource to false, so
515
+ # `Frame.iframeAddedCallback` now returns early unless `iframe` is set:
516
+ # the parser still registers the `<iframe>` in the DOM, but no child
517
+ # frame is ever created. That silently breaks `switch_to_frame` and
518
+ # Capybara's `within_frame` — core driver API, not an optional extra —
519
+ # and there is no CLI opt-in the gem can use below 8946, because the
520
+ # `--load-resources` *value* `iframe` only parses from that build.
521
+ #
522
+ # The `subFrame`/`worker` params predate #3305 and simply write a
523
+ # different field after it, which makes this the one form that spans the
524
+ # whole supported range: a no-op below 8946 where both already defaulted
525
+ # on, load-bearing above it, and no MINIMUM_NIGHTLY_BUILD bump either way.
526
+ #
527
+ # Workers are included to hold the gem's behavior still across the flip
528
+ # rather than to satisfy a feature we ship: a worker that silently never
529
+ # runs is the same debugging trap as an iframe that silently never loads.
530
+ #
531
+ # Not rescued on purpose. LP.configureLoading predates the floor by a
532
+ # wide margin, so a failure here means the endpoint is not a Lightpanda
533
+ # we understand, and swallowing it would restore the exact silence this
534
+ # call exists to prevent.
535
+ def configure_loading
536
+ page_command("LP.configureLoading", subFrame: true, worker: true)
537
+ end
538
+
501
539
  def register_auto_scripts
502
540
  page_command("Page.addScriptToEvaluateOnNewDocument", source: AutoScripts::JS)
503
541
  end
@@ -590,6 +628,44 @@ module Capybara
590
628
  end
591
629
  end
592
630
 
631
+ # Binary.update_hint would be a lie here: it prints a curl into a path the
632
+ # gem controls, and under ws_url the browser is somebody else's process on
633
+ # possibly another host.
634
+ REMOTE_UPDATE_HINT = "the gem does not manage that browser — " \
635
+ "update the Lightpanda serving ws_url yourself."
636
+ private_constant :REMOTE_UPDATE_HINT
637
+
638
+ # The spawn path runs `lightpanda version` before the browser is even up
639
+ # (Process#check_minimum_version). An externally-managed browser has no
640
+ # binary to shell out to, so the same floor is enforced over CDP instead:
641
+ # `LP.version` returns the identical string the CLI prints, and
642
+ # Process.check_version! is the one parser both channels share.
643
+ #
644
+ # Without this, `ws_url:` was the one way into the driver that skipped the
645
+ # floor entirely — an old browser connected happily and then failed later
646
+ # as an unrelated-looking CDP error, with nothing naming the real cause.
647
+ #
648
+ # A browser that cannot answer `LP.version` is refused rather than assumed
649
+ # new enough, matching the spawn path's rule that an unidentifiable binary
650
+ # never passes. Two things land here: a Lightpanda predating the LP domain,
651
+ # and a Chrome someone pointed ws_url at by mistake — the second being easy
652
+ # to do and previously diagnosable only by watching later commands fail.
653
+ #
654
+ # Only #start calls this. #reconnect deliberately does not: it re-dials the
655
+ # same endpoint, whose version cannot have changed, and the crash-recovery
656
+ # path should not spend a round-trip re-asking.
657
+ def check_remote_version
658
+ result = @client.command("LP.version")
659
+ @remote_version, @remote_nightly_build, @remote_release =
660
+ Process.check_version!(result["version"]) { REMOTE_UPDATE_HINT }
661
+ rescue BrowserError, TimeoutError => e
662
+ raise BinaryError,
663
+ "Could not read the Lightpanda version at #{@options.ws_url} " \
664
+ "(LP.version failed: #{e.message}). ws_url: must point at a " \
665
+ "Lightpanda CDP endpoint new enough to answer LP.version; the gem " \
666
+ "cannot verify a browser it does not manage."
667
+ end
668
+
593
669
  def restart_process_if_dead
594
670
  return unless @process && !@process.alive?
595
671
 
@@ -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
@@ -221,9 +256,25 @@ module Capybara
221
256
  raise Capybara::UnselectNotAllowed, "Cannot unselect option from single select box."
222
257
  end
223
258
 
259
+ # The trailing `wait_for_idle` mirrors #click, and for the same reason.
260
+ # Since upstream #3264 (build >= 8842) `Input.dispatchKeyEvent` builds a
261
+ # *trusted* KeyboardEvent, so Enter on a submit input / <a href> and
262
+ # Space on a button or checkbox synthesize a real activation click —
263
+ # which means a keystroke can now start a navigation. Before #3264 it
264
+ # could not, which is why this settle step was historically absent; its
265
+ # absence then showed up as `current_url` being read against the
266
+ # outgoing document (test/features/keyboard_activation_test.rb).
267
+ #
268
+ # Caveat, shared with #click: `wait_for_idle` only sniffs for ~50 ms
269
+ # (Browser::SNIFF_WINDOW) and watches Runtime.executionContextsCleared,
270
+ # which Lightpanda emits when the *response* lands, not when the request
271
+ # starts. A navigation whose server round-trip exceeds that window is
272
+ # therefore still not awaited here — use Capybara's waiting matchers
273
+ # (`have_current_path`) for those, as you would after a click.
224
274
  def send_keys(*)
225
275
  call("function() { this.focus() }")
226
276
  driver.browser.keyboard.type(*)
277
+ driver.browser.wait_for_idle
227
278
  end
228
279
 
229
280
  def tag_name
@@ -645,6 +696,151 @@ module Capybara
645
696
  }
646
697
  JS
647
698
 
699
+ # Selenium's MOUSEDOWN_TRACKER + LEGACY_DRAG_CHECK folded into one round
700
+ # trip. Selenium presses a real mouse button before checking; we dispatch
701
+ # a synthetic mousedown so drag libraries that preventDefault on it
702
+ # (mouse-based / fallback DnD) still steer the check toward the legacy
703
+ # path. Returns true when the drag would need the legacy (coordinate)
704
+ # path: mousedown prevented / never observed, or no draggable ancestor.
705
+ LEGACY_DRAG_CHECK_JS = <<~JS
706
+ function() {
707
+ var doc = this.ownerDocument || document;
708
+ var prevented = null;
709
+ doc.addEventListener('mousedown', function(ev) { prevented = ev.defaultPrevented; }, { once: true });
710
+ this.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
711
+ if (prevented === true || prevented === null) return true;
712
+ var el = this;
713
+ do {
714
+ if (el.draggable) return false;
715
+ } while ((el = el.parentElement));
716
+ return true;
717
+ }
718
+ JS
719
+
720
+ # Ported near-verbatim from Capybara's Selenium driver
721
+ # (capybara/selenium/extensions/html5_drag.rb, HTML5_DRAG_DROP_SCRIPT) —
722
+ # the same source Cuprite's #315 drag.js ports — kept close to ease
723
+ # future syncs. Upstream quirks preserved deliberately: `rectPt.top` in
724
+ # pointOnRect (DOMPoint has no .top, that branch just falls through), the
725
+ # undeclared `key` loop variable, and `callback.call(true)`. The
726
+ # `source.draggable` reads are native — the HTMLElement.draggable IDL
727
+ # shipped upstream in #3257 (build 8793, guaranteed by the floor; the
728
+ # `_lightpanda.isDraggable` polyfill was retired with that bump).
729
+ # Coordinates come from getBoundingClientRect, which Lightpanda
730
+ # synthesizes without layout — dropzones reading clientX/Y get
731
+ # plausible-but-synthetic (integer since #3259) numbers.
732
+ HTML5_DRAG_JS = <<~JS
733
+ function rectCenter(rect){
734
+ return new DOMPoint(
735
+ (rect.left + rect.right)/2,
736
+ (rect.top + rect.bottom)/2
737
+ );
738
+ }
739
+
740
+ function pointOnRect(pt, rect) {
741
+ var rectPt = rectCenter(rect);
742
+ var slope = (rectPt.y - pt.y) / (rectPt.x - pt.x);
743
+
744
+ if (pt.x <= rectPt.x) { // left side
745
+ var minXy = slope * (rect.left - pt.x) + pt.y;
746
+ if (rect.top <= minXy && minXy <= rect.bottom)
747
+ return new DOMPoint(rect.left, minXy);
748
+ }
749
+
750
+ if (pt.x >= rectPt.x) { // right side
751
+ var maxXy = slope * (rect.right - pt.x) + pt.y;
752
+ if (rect.top <= maxXy && maxXy <= rect.bottom)
753
+ return new DOMPoint(rect.right, maxXy);
754
+ }
755
+
756
+ if (pt.y <= rectPt.y) { // top side
757
+ var minYx = (rectPt.top - pt.y) / slope + pt.x;
758
+ if (rect.left <= minYx && minYx <= rect.right)
759
+ return new DOMPoint(minYx, rect.top);
760
+ }
761
+
762
+ if (pt.y >= rectPt.y) { // bottom side
763
+ var maxYx = (rect.bottom - pt.y) / slope + pt.x;
764
+ if (rect.left <= maxYx && maxYx <= rect.right)
765
+ return new DOMPoint(maxYx, rect.bottom);
766
+ }
767
+
768
+ return new DOMPoint(pt.x,pt.y);
769
+ }
770
+
771
+ function dragEnterTarget() {
772
+ target.scrollIntoView({behavior: 'instant', block: 'center', inline: 'center'});
773
+ var targetRect = target.getBoundingClientRect();
774
+ var sourceCenter = rectCenter(source.getBoundingClientRect());
775
+
776
+ for (var i = 0; i < drop_modifier_keys.length; i++) {
777
+ key = drop_modifier_keys[i];
778
+ if (key == "control"){
779
+ key = "ctrl"
780
+ }
781
+ opts[key + 'Key'] = true;
782
+ }
783
+
784
+ var dragEnterEvent = new DragEvent('dragenter', opts);
785
+ target.dispatchEvent(dragEnterEvent);
786
+
787
+ // fire 2 dragover events to simulate dragging with a direction
788
+ var entryPoint = pointOnRect(sourceCenter, targetRect)
789
+ var dragOverOpts = Object.assign({clientX: entryPoint.x, clientY: entryPoint.y}, opts);
790
+ var dragOverEvent = new DragEvent('dragover', dragOverOpts);
791
+ target.dispatchEvent(dragOverEvent);
792
+ window.setTimeout(dragOnTarget, step_delay);
793
+ }
794
+
795
+ function dragOnTarget() {
796
+ var targetCenter = rectCenter(target.getBoundingClientRect());
797
+ var dragOverOpts = Object.assign({clientX: targetCenter.x, clientY: targetCenter.y}, opts);
798
+ var dragOverEvent = new DragEvent('dragover', dragOverOpts);
799
+ target.dispatchEvent(dragOverEvent);
800
+ window.setTimeout(dragLeave, step_delay, dragOverEvent.defaultPrevented, dragOverOpts);
801
+ }
802
+
803
+ function dragLeave(drop, dragOverOpts) {
804
+ var dragLeaveOptions = Object.assign({}, opts, dragOverOpts);
805
+ var dragLeaveEvent = new DragEvent('dragleave', dragLeaveOptions);
806
+ target.dispatchEvent(dragLeaveEvent);
807
+ if (drop) {
808
+ var dropEvent = new DragEvent('drop', dragLeaveOptions);
809
+ target.dispatchEvent(dropEvent);
810
+ }
811
+ var dragEndEvent = new DragEvent('dragend', dragLeaveOptions);
812
+ source.dispatchEvent(dragEndEvent);
813
+ callback.call(true);
814
+ }
815
+
816
+ var source = arguments[0],
817
+ target = arguments[1],
818
+ step_delay = arguments[2],
819
+ drop_modifier_keys = arguments[3],
820
+ callback = arguments[4];
821
+
822
+ var dt = new DataTransfer();
823
+ var opts = { cancelable: true, bubbles: true, dataTransfer: dt };
824
+
825
+ while (source && !source.draggable) {
826
+ source = source.parentElement;
827
+ }
828
+
829
+ if (source.tagName == 'A'){
830
+ dt.setData('text/uri-list', source.href);
831
+ dt.setData('text', source.href);
832
+ }
833
+ if (source.tagName == 'IMG'){
834
+ dt.setData('text/uri-list', source.src);
835
+ dt.setData('text', source.src);
836
+ }
837
+
838
+ var dragEvent = new DragEvent('dragstart', opts);
839
+ source.dispatchEvent(dragEvent);
840
+
841
+ window.setTimeout(dragEnterTarget, step_delay);
842
+ JS
843
+
648
844
  VISIBLE_JS = "function() { return _lightpanda.isVisible(this); }"
649
845
 
650
846
  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,46 @@ 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
+ #
205
+ # 2026-08-26 bump 8796 -> 8875, four fixes the gem now asserts on:
206
+ # #3264 (8842) CDP Input.dispatchKeyEvent builds a *trusted*
207
+ # KeyboardEvent, so frame/user_input.zig fires `keypress` on
208
+ # printable/Enter keydowns and synthesizes a trusted PointerEvent
209
+ # click for Enter on <button>/<a href>/input[submit|button|reset|
210
+ # image] and Space-keyup on those plus checkbox/radio. That click is
211
+ # a real activation event (PointerEvent.Proto = MouseEvent), so
212
+ # Node#send_keys(:enter) submits/navigates and send_keys(:space)
213
+ # toggles a checkbox — both were no-ops below 8842. No gem code
214
+ # drives it; Keyboard never sends CDP `type: "char"`, so nothing
215
+ # double-fires. Pinned by test/features/keyboard_activation_test.rb.
216
+ # #3269 (8868) adds `dialog:not([open]) { display: none }` to the
217
+ # UA-stylesheet truth checkVisibility() and getComputedStyle().display
218
+ # share, so a closed <dialog> AND its subtree read as non-visible.
219
+ # Below 8868 Capybara matched text and controls inside a never-opened
220
+ # dialog. Pinned by test/features/upstream_bugs_test.rb (Bug #4).
221
+ # #3270 (8857) inline-style keyword matching in checkVisibility /
222
+ # getComputedStyle is case-insensitive for display / visibility /
223
+ # opacity / pointer-events, so `style="display: NONE"` hides.
224
+ # Feeds _lightpanda.isVisible. Pinned by visibility_keywords_test.rb.
225
+ # #3256 (8875) the CDP WS handshake accepts the exact lowercase
226
+ # `Host: localhost:<port>` form (bare `localhost`, `LOCALHOST:<port>`
227
+ # and `localhost.evil.com:<port>` still 403). Below 8875 only an IP
228
+ # literal passed, so a user-supplied `ws_url:` had to say 127.0.0.1.
229
+ # Pinned by test/features/ws_url_host_test.rb.
230
+ # Build 8875 = the #3256 merge (f2169836e). NOTE: this floor leads the
231
+ # nightly channel — the 2026-08-26 nightly is 8855 — so it lands with the
232
+ # next nightly cut. #3267 (8880, Authorization stripped on cross-origin
233
+ # redirects) arrives with the same floor but drives nothing gem-side.
234
+ MINIMUM_NIGHTLY_BUILD = Gem::Version.new("8875")
195
235
 
196
236
  # Second, equivalent floor for the *release* channel.
197
237
  #
@@ -200,15 +240,71 @@ module Capybara
200
240
  # bare "0.3.6" carrying no commit counter at all, and MINIMUM_NIGHTLY_BUILD
201
241
  # has nothing to compare against. Releases are cut from the same trunk, so
202
242
  # 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.)
243
+ # nightly floor. As of 2026-08-26 NO published release clears the 8875
244
+ # floor 0.3.7 (build 8671) predates every fix the floor exists for — so
245
+ # this pin names the NEXT release. 0.3.8 is not tagged yet; any future tag
246
+ # is cut from a trunk already past build 8886, so it clears the floor by
247
+ # construction. Until upstream tags it, the release channel is
248
+ # deliberately unusable (nightly-only), reported with the standard
249
+ # too-old error. When 0.3.8 ships: verify
250
+ # `git rev-list --count 0.3.8` >= 8875 and update the lockstep table in
251
+ # process_test.rb.
206
252
  #
207
253
  # INVARIANT: every MINIMUM_NIGHTLY_BUILD bump must also move this to the
208
254
  # first release containing that build (`git rev-list --count <tag>` in the
209
255
  # browser repo tells you). Leaving it behind would let the release channel
210
256
  # silently accept a binary the nightly channel rejects.
211
- MINIMUM_RELEASE = Gem::Version.new("0.3.7")
257
+ MINIMUM_RELEASE = Gem::Version.new("0.3.8")
258
+
259
+ class << self
260
+ # `lightpanda version` prints one of two shapes, and the gem supports
261
+ # both so a suite can either track nightly or pin a reproducible
262
+ # release:
263
+ #
264
+ # "1.0.0-nightly.8285+de85a51d" rolling nightly (also "dev.NNNN" for
265
+ # a locally compiled tree — same
266
+ # `git rev-list --count HEAD` counter,
267
+ # different label). Checked against
268
+ # MINIMUM_NIGHTLY_BUILD.
269
+ # "0.3.5" a tagged release: bare semver, no
270
+ # build metadata. Checked against
271
+ # MINIMUM_RELEASE.
272
+ #
273
+ # The release form is matched anchored — a bare semver and nothing else
274
+ # — so a stray "1.2.3" inside some other string can never be mistaken
275
+ # for a release. Anything that matches neither shape stays a hard
276
+ # failure: a version we cannot identify is never assumed to be new
277
+ # enough.
278
+ #
279
+ # Lives on the class, not the instance, because there are two ways to
280
+ # learn a version and only one of them involves a Process: the spawn
281
+ # path shells out to the binary, while an externally-managed browser
282
+ # (`ws_url:`) is asked over CDP. `LP.version` returns the identical
283
+ # string the CLI prints, so both channels share this one parser rather
284
+ # than drifting apart — the drift being how `ws_url:` shipped with no
285
+ # floor check at all.
286
+ #
287
+ # Returns [version, nightly_build, release]; exactly one of the latter
288
+ # two is non-nil. The update hint is yielded rather than passed so the
289
+ # caller's hint-building only runs on the failure path.
290
+ def check_version!(raw)
291
+ version = raw.to_s.strip
292
+
293
+ if (build = version[/(?:nightly|dev)\.(\d+)/, 1])
294
+ nightly_build = Gem::Version.new(build)
295
+ return [version, nightly_build, nil] if nightly_build >= MINIMUM_NIGHTLY_BUILD
296
+ elsif (tag = version[/\A\d+\.\d+\.\d+\z/])
297
+ release = Gem::Version.new(tag)
298
+ return [version, nil, release] if release >= MINIMUM_RELEASE
299
+ end
300
+
301
+ raise BinaryError,
302
+ "Lightpanda #{version} is too old. " \
303
+ "This gem requires nightly build >= #{MINIMUM_NIGHTLY_BUILD} " \
304
+ "or release >= #{MINIMUM_RELEASE}. " \
305
+ "Update: #{yield}"
306
+ end
307
+ end
212
308
 
213
309
  attr_reader :pid, :ws_url, :version, :nightly_build, :release
214
310
 
@@ -257,38 +353,12 @@ module Capybara
257
353
 
258
354
  private
259
355
 
260
- # `lightpanda version` prints one of two shapes, and the gem supports both
261
- # so a suite can either track nightly or pin a reproducible release:
262
- #
263
- # "1.0.0-nightly.8285+de85a51d" rolling nightly (also "dev.NNNN" for a
264
- # locally compiled tree — same
265
- # `git rev-list --count HEAD` counter,
266
- # different label). Checked against
267
- # MINIMUM_NIGHTLY_BUILD.
268
- # "0.3.5" a tagged release: bare semver, no build
269
- # metadata. Checked against MINIMUM_RELEASE.
270
- #
271
- # The release form is matched anchored — a bare semver and nothing else —
272
- # so a stray "1.2.3" inside some other string can never be mistaken for a
273
- # release. Anything that matches neither shape stays a hard failure: a
274
- # binary we cannot identify is never assumed to be new enough.
356
+ # Shells the floor check out to the binary. The ws_url path can't — see
357
+ # Browser#check_remote_version, which feeds the same parser from CDP.
275
358
  def check_minimum_version(binary_path)
276
359
  stdout, = Open3.capture3(binary_path, "version")
277
- @version = stdout.strip
278
-
279
- if (build = @version[/(?:nightly|dev)\.(\d+)/, 1])
280
- @nightly_build = Gem::Version.new(build)
281
- return if @nightly_build >= MINIMUM_NIGHTLY_BUILD
282
- elsif (tag = @version[/\A\d+\.\d+\.\d+\z/])
283
- @release = Gem::Version.new(tag)
284
- return if @release >= MINIMUM_RELEASE
285
- end
286
-
287
- raise BinaryError,
288
- "Lightpanda #{@version} is too old. " \
289
- "This gem requires nightly build >= #{MINIMUM_NIGHTLY_BUILD} " \
290
- "or release >= #{MINIMUM_RELEASE}. " \
291
- "Update: #{Binary.update_hint(binary_path)}"
360
+ @version, @nightly_build, @release =
361
+ self.class.check_version!(stdout) { Binary.update_hint(binary_path) }
292
362
  rescue Errno::ENOENT
293
363
  # Binary not runnable — let attempt_start handle it
294
364
  end
@@ -355,7 +425,7 @@ module Capybara
355
425
  "info",
356
426
  # External stylesheet fetch (PR #2487, build >= 6353 — enforced by the
357
427
  # floor). Always on so linked CSS contributes to checkVisibility /
358
- # getComputedStyle; see .claude/rules/lightpanda-io.md limitation #6.
428
+ # getComputedStyle; see .claude/rules/lightpanda-io.md limitation #5.
359
429
  "--enable-external-stylesheets",
360
430
  # Raise the inbound CDP WebSocket message cap from Lightpanda's 1 MiB
361
431
  # default (Config.zig `cdp_max_message_size`) to 100 MiB, matching
@@ -371,6 +441,12 @@ module Capybara
371
441
  "--cdp-max-message-size",
372
442
  (100 * 1024 * 1024).to_s,
373
443
  ]
444
+ # Opt-in image fetching (upstream #3230, build >= 8834). Deliberately
445
+ # NOT always-on like --enable-external-stylesheets: images never feed
446
+ # the DOM predicates, so the default spends no bandwidth on them. On
447
+ # builds < 8834 the flag is a fatal UnknownOption at boot — see
448
+ # Options#load_images.
449
+ base.push("--load-resources", "image") if @options.load_images
374
450
  extra = ENV.fetch("LIGHTPANDA_EXTRA_ARGS", "").split
375
451
  base + extra
376
452
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Capybara
4
4
  module Lightpanda
5
- VERSION = "0.10.0"
5
+ VERSION = "0.12.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.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Navid Emad