From 20d367116cfe29139df8e8a9904a8ea2eb94844a Mon Sep 17 00:00:00 2001 From: Dmitry Vorotilin Date: Fri, 16 Oct 2026 17:49:24 +0300 Subject: [PATCH 1/2] fix(drag): end an HTML5 drag with either drop or dragleave, like a browser drag.js fired dragleave right before drop on the target, so Pragmatic drag and drop treated the drag as having left the window and dropped onto nothing. Also fix the entry point from above or below a target and the leaked key global. Capybara's shared spec that requires dragleave alongside drop is skipped. --- CHANGELOG.md | 3 ++ lib/capybara/cuprite/javascripts/drag.js | 30 ++++++++----------- spec/features/session_spec.rb | 18 +++++++++++ spec/spec_helper.rb | 7 +++++ spec/support/views/html5_drag.erb | 38 ++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 18 deletions(-) create mode 100644 spec/support/views/html5_drag.erb diff --git a/CHANGELOG.md b/CHANGELOG.md index 950114d..d5627ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ `#active_element` and `focused:` specs run in the suite [#327] ### Fixed +- `Node#drag_to` fired `dragleave` right before `drop` on an HTML5 drop target, which browsers never do, so libraries such + as Pragmatic drag and drop dropped onto nothing. A drag now ends with `drop` if the target accepted it, otherwise + with `dragleave` [#325] - `window_size: nil` raised `Ferrum::BrowserError: Invalid parameters` on every page instead of leaving the window size to Chrome, so `start-maximized` couldn't be used [#278] - `Driver#reset!` kept the page it had cached when the browser failed to reset, so every later example raised diff --git a/lib/capybara/cuprite/javascripts/drag.js b/lib/capybara/cuprite/javascripts/drag.js index 03de508..b42745d 100644 --- a/lib/capybara/cuprite/javascripts/drag.js +++ b/lib/capybara/cuprite/javascripts/drag.js @@ -1,11 +1,9 @@ // HTML5 drag-and-drop emulation. // -// Ported near-verbatim from Capybara's Selenium driver -// (capybara/selenium/extensions/html5_drag.rb, HTML5_DRAG_DROP_SCRIPT) so -// Cuprite matches Capybara's HTML5 drag behaviour and its shared specs. Kept -// close to the source to ease future syncs; known upstream quirks (rectPt.top -// in pointOnRect, undeclared `key`, callback.call(true)) are preserved -// deliberately. See https://github.com/rubycdp/cuprite/issues/314. +// Ported from Capybara's Selenium driver (capybara/selenium/extensions/html5_drag.rb, +// HTML5_DRAG_DROP_SCRIPT), see https://github.com/rubycdp/cuprite/issues/314. +// Unlike the original, a drag ends the way it does in a browser: with `drop` if +// the target accepted it, otherwise with `dragleave`, never both [#325]. function rectCenter(rect){ return new DOMPoint( @@ -31,7 +29,7 @@ function pointOnRect(pt, rect) { } if (pt.y <= rectPt.y) { // top side - var minYx = (rectPt.top - pt.y) / slope + pt.x; + var minYx = (rect.top - pt.y) / slope + pt.x; if (rect.left <= minYx && minYx <= rect.right) return new DOMPoint(minYx, rect.top); } @@ -51,7 +49,7 @@ function dragEnterTarget() { var sourceCenter = rectCenter(source.getBoundingClientRect()); for (var i = 0; i < drop_modifier_keys.length; i++) { - key = drop_modifier_keys[i]; + var key = drop_modifier_keys[i]; if (key == "control"){ key = "ctrl" } @@ -74,18 +72,14 @@ function dragOnTarget() { var dragOverOpts = Object.assign({clientX: targetCenter.x, clientY: targetCenter.y}, opts); var dragOverEvent = new DragEvent('dragover', dragOverOpts); target.dispatchEvent(dragOverEvent); - window.setTimeout(dragLeave, step_delay, dragOverEvent.defaultPrevented, dragOverOpts); + window.setTimeout(dragEnd, step_delay, dragOverEvent.defaultPrevented, dragOverOpts); } -function dragLeave(drop, dragOverOpts) { - var dragLeaveOptions = Object.assign({}, opts, dragOverOpts); - var dragLeaveEvent = new DragEvent('dragleave', dragLeaveOptions); - target.dispatchEvent(dragLeaveEvent); - if (drop) { - var dropEvent = new DragEvent('drop', dragLeaveOptions); - target.dispatchEvent(dropEvent); - } - var dragEndEvent = new DragEvent('dragend', dragLeaveOptions); +function dragEnd(drop, dragOverOpts) { + var dragEndOptions = Object.assign({}, opts, dragOverOpts); + var dropOrLeaveEvent = new DragEvent(drop ? 'drop' : 'dragleave', dragEndOptions); + target.dispatchEvent(dropOrLeaveEvent); + var dragEndEvent = new DragEvent('dragend', dragEndOptions); source.dispatchEvent(dragEndEvent); callback.call(true); } diff --git a/spec/features/session_spec.rb b/spec/features/session_spec.rb index c5339b2..451464e 100644 --- a/spec/features/session_spec.rb +++ b/spec/features/session_spec.rb @@ -867,6 +867,24 @@ end end + context "HTML5 dragging support" do + before do + @session.visit "/cuprite/html5_drag" + end + + it "ends with drop and no dragleave on a target that accepts it" do + @session.find(:css, "#source").drag_to(@session.find(:css, "#accepting")) + + expect(@session.evaluate_script("window.events.accepting")).to eq(%w[dragenter dragover drop]) + end + + it "ends with dragleave and no drop on a target that refuses it" do + @session.find(:css, "#source").drag_to(@session.find(:css, "#refusing")) + + expect(@session.evaluate_script("window.events.refusing")).to eq(%w[dragenter dragover dragleave]) + end + end + context "window switching support" do it "waits for the window to load" do @session.visit "/" diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 74f9f6e..ea16b31 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -61,8 +61,15 @@ module TestSessions node #set should submit single text input forms if ended with REGEXP + browser_behavior = <<~REGEXP.split("\n").map { |s| Regexp.quote(s.strip) }.join("|") + #drag_to HTML5 should preserve clientX/Y from last dragover event + REGEXP + metadata[:skip] = true if metadata[:full_description].match(/#{regexes}/) metadata[:skip] = "Intentionally skipped" if metadata[:full_description].match(/#{intentional_skip}/) + if metadata[:full_description].match(/#{browser_behavior}/) + metadata[:skip] = "Expects behavior browsers don't have, e.g. dragleave together with drop" + end end config.around do |example| diff --git a/spec/support/views/html5_drag.erb b/spec/support/views/html5_drag.erb new file mode 100644 index 0000000..0f9dc10 --- /dev/null +++ b/spec/support/views/html5_drag.erb @@ -0,0 +1,38 @@ + + + + cuprite html5 drag_to + + + +
Drag me
+
Drop here
+
Not here
+ + + + From d6a2697265fa730a477c5bafd803676f38e69dec Mon Sep 17 00:00:00 2001 From: Dmitry Vorotilin Date: Fri, 16 Oct 2026 18:07:38 +0300 Subject: [PATCH 2/2] feat(drag): drop at an offset from the target with x: and y: in drag_to Measured from the target's center or top left like click, following offset: or Capybara.w3c_click_offset, on both the HTML5 and the mouse path, so drop targets that tell the upper half from the lower one can be driven [#325]. --- CHANGELOG.md | 2 ++ docs/6-node.md | 4 ++++ lib/capybara/cuprite/browser.rb | 9 ++++---- lib/capybara/cuprite/javascripts/drag.js | 14 +++++++++--- lib/capybara/cuprite/node.rb | 12 +++++++++- spec/features/session_spec.rb | 28 ++++++++++++++++++++++++ 6 files changed, 61 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5627ce..f92d816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ### Added - `:emulate` driver option to emulate a device listed in Chrome DevTools with its user agent, e.g. `emulate: :iphone_14`, along with `:mobile` and `:scale_factor` options it sets, kept across window resizes [#216] +- `Node#drag_to` accepts `x:` and `y:` to drop at an offset from the target, measured from its center or top left like + `click`, so drop targets that tell the upper half from the lower one can be driven [#325] ### Changed - Ferrum is bumped to `~> 0.19.0` diff --git a/docs/6-node.md b/docs/6-node.md index 218144e..6af9c4b 100644 --- a/docs/6-node.md +++ b/docs/6-node.md @@ -79,9 +79,13 @@ automatically. * :scroll `Boolean` scroll elements into view, `true` by default * :html5 `Boolean` force HTML5 or legacy mouse drag * :drop_modifiers `Symbol` | `Array` modifier keys held on drop, e.g. `:shift`, `:ctrl`, `:alt`, `:meta` + * :x, :y `Integer` drop at this offset instead of the center of `other`, both are required + * :offset `:center` | `:top` where the offset is measured from, the center or the top left corner of `other`. + Follows `Capybara.w3c_click_offset` by default, like `click` ```ruby find("#item").drag_to(find("#basket"), steps: 10, drop_modifiers: :shift) +find("#item").drag_to(find("#list li", text: "Milk"), x: 0, y: 10) # drop on the lower half ``` #### drag_by(x, y, \*\*options) diff --git a/lib/capybara/cuprite/browser.rb b/lib/capybara/cuprite/browser.rb index 7c385a0..b3b82ee 100644 --- a/lib/capybara/cuprite/browser.rb +++ b/lib/capybara/cuprite/browser.rb @@ -155,6 +155,7 @@ def drag(node, other, drop_modifiers, options = {}) delay = options[:delay] scroll = options.fetch(:scroll, true) html5 = options[:html5] + drop_offset = options[:drop_offset] execute("_cuprite.dragMousedownTracker()") @@ -168,21 +169,21 @@ def drag(node, other, drop_modifiers, options = {}) html5 = !evaluate_on(node: node, expression: "_cuprite.legacyDragCheck(this)") if html5.nil? if html5 - drag_html5(node, other, drop_modifiers, delay) + drag_html5(node, other, drop_modifiers, delay, drop_offset) else modifiers = keyboard.modifiers(drop_modifiers) other.scroll_into_view if scroll - x2, y2 = other.find_position + x2, y2 = other.find_position(**drop_offset.to_h) mouse.move(x: x2, y: y2, steps: steps) mouse.up(modifiers: modifiers) end end - def drag_html5(node, other, drop_modifiers, delay) + def drag_html5(node, other, drop_modifiers, delay, drop_offset = nil) keys = drop_modifiers.map(&:to_s) - evaluate_async(DRAG_HTML5_JS, timeout, node, other, (delay || 0.05) * 1000, keys) + evaluate_async(DRAG_HTML5_JS, timeout, node, other, (delay || 0.05) * 1000, keys, drop_offset) mouse.up end diff --git a/lib/capybara/cuprite/javascripts/drag.js b/lib/capybara/cuprite/javascripts/drag.js index b42745d..cbbc96b 100644 --- a/lib/capybara/cuprite/javascripts/drag.js +++ b/lib/capybara/cuprite/javascripts/drag.js @@ -67,9 +67,16 @@ function dragEnterTarget() { window.setTimeout(dragOnTarget, step_delay); } +function dropPoint(rect) { + if (!drop_offset) return rectCenter(rect); + + var origin = drop_offset.position == 'center' ? rectCenter(rect) : new DOMPoint(rect.left, rect.top); + return new DOMPoint(origin.x + drop_offset.x, origin.y + drop_offset.y); +} + function dragOnTarget() { - var targetCenter = rectCenter(target.getBoundingClientRect()); - var dragOverOpts = Object.assign({clientX: targetCenter.x, clientY: targetCenter.y}, opts); + var point = dropPoint(target.getBoundingClientRect()); + var dragOverOpts = Object.assign({clientX: point.x, clientY: point.y}, opts); var dragOverEvent = new DragEvent('dragover', dragOverOpts); target.dispatchEvent(dragOverEvent); window.setTimeout(dragEnd, step_delay, dragOverEvent.defaultPrevented, dragOverOpts); @@ -88,7 +95,8 @@ var source = arguments[0], target = arguments[1], step_delay = arguments[2], drop_modifier_keys = arguments[3], - callback = arguments[4]; + drop_offset = arguments[4], + callback = arguments[5]; var dt = new DataTransfer(); var opts = { cancelable: true, bubbles: true, dataTransfer: dt }; diff --git a/lib/capybara/cuprite/node.rb b/lib/capybara/cuprite/node.rb index d14144d..8160703 100644 --- a/lib/capybara/cuprite/node.rb +++ b/lib/capybara/cuprite/node.rb @@ -181,11 +181,14 @@ def hover end def drag_to(other, **options) + raise ArgumentError, "You must specify both x: and y: for a drop offset" if options[:x].nil? ^ options[:y].nil? + options[:steps] ||= 1 options[:scroll] = true unless options.key?(:scroll) + options[:drop_offset] = drop_offset(*options.values_at(:x, :y, :offset)) modifiers = Array(options[:drop_modifiers]).map { |m| DRAG_MODIFIER_ALIASES.fetch(m.to_sym, m.to_sym) } - command(:drag, other.node, modifiers, options.slice(:steps, :delay, :scroll, :html5)) + command(:drag, other.node, modifiers, options.slice(:steps, :delay, :scroll, :html5, :drop_offset)) end def drag_by(x, y, **options) @@ -282,6 +285,13 @@ def prepare_and_click(mode, name, keys, options) node.click(mode: mode, keys: keys, offset: offset, delay: delay) end + def drop_offset(x, y, position) + return unless x + + position ||= driver.session_options.w3c_click_offset ? :center : :top + { x: x, y: y, position: position.to_sym } + end + def filter_text(text) text.gsub(/[\u200b\u200e\u200f]/, "") .gsub(/[\ \n\f\t\v\u2028\u2029]+/, " ") diff --git a/spec/features/session_spec.rb b/spec/features/session_spec.rb index 451464e..224d013 100644 --- a/spec/features/session_spec.rb +++ b/spec/features/session_spec.rb @@ -883,6 +883,34 @@ expect(@session.evaluate_script("window.events.refusing")).to eq(%w[dragenter dragover dragleave]) end + + [true, false].each do |html5| + it "drops at an offset from the target's center with html5: #{html5}" do + @session.find(:css, "#source").drag_to(@session.find(:css, "#accepting"), x: 10, y: 5, html5: html5) + + expect(@session.evaluate_script("window.dropPoint")).to eq([110, 55]) + end + + it "drops at an offset from the target's top left with html5: #{html5}" do + @session.find(:css, "#source") + .drag_to(@session.find(:css, "#accepting"), x: 10, y: 5, offset: :top, html5: html5) + + expect(@session.evaluate_script("window.dropPoint")).to eq([10, 5]) + end + end + + it "drops at an offset from the target's top left when w3c_click_offset is off" do + Capybara.w3c_click_offset = false + @session.find(:css, "#source").drag_to(@session.find(:css, "#accepting"), x: 10, y: 5) + + expect(@session.evaluate_script("window.dropPoint")).to eq([10, 5]) + end + + it "requires both x: and y: for a drop offset" do + expect do + @session.find(:css, "#source").drag_to(@session.find(:css, "#accepting"), x: 10) + end.to raise_error(ArgumentError, "You must specify both x: and y: for a drop offset") + end end context "window switching support" do