Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -11,6 +13,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
Expand Down
4 changes: 4 additions & 0 deletions docs/6-node.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 5 additions & 4 deletions lib/capybara/cuprite/browser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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()")

Expand All @@ -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

Expand Down
44 changes: 23 additions & 21 deletions lib/capybara/cuprite/javascripts/drag.js
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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);
}
Expand All @@ -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"
}
Expand All @@ -69,23 +67,26 @@ 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(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);
}
Expand All @@ -94,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 };
Expand Down
12 changes: 11 additions & 1 deletion lib/capybara/cuprite/node.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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]+/, " ")
Expand Down
46 changes: 46 additions & 0 deletions spec/features/session_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -867,6 +867,52 @@
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

[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
it "waits for the window to load" do
@session.visit "/"
Expand Down
7 changes: 7 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
38 changes: 38 additions & 0 deletions spec/support/views/html5_drag.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<meta http-equiv="Content-type" content="text/html; charset=utf-8"/>
<title>cuprite html5 drag_to</title>
<style type="text/css">
body { margin: 0; }
#source, #accepting, #refusing { position: absolute; width: 200px; height: 100px; }
#source { left: 0; top: 0; }
#accepting { left: 300px; top: 0; }
#refusing { left: 300px; top: 200px; }
</style>
</head>
<body>
<div id="source" draggable="true">Drag me</div>
<div id="accepting">Drop here</div>
<div id="refusing">Not here</div>

<script type="text/javascript">
window.events = { accepting: [], refusing: [] };
window.dropPoint = null;

for (const id of ["accepting", "refusing"]) {
const target = document.getElementById(id);
for (const type of ["dragenter", "dragover", "dragleave", "drop"]) {
target.addEventListener(type, event => {
const events = window.events[id];
if (events[events.length - 1] !== type) events.push(type);
if (id === "accepting" && type !== "dragleave") event.preventDefault();
if (type === "drop") {
const rect = target.getBoundingClientRect();
window.dropPoint = [Math.round(event.clientX - rect.left), Math.round(event.clientY - rect.top)];
}
});
}
}
</script>
</body>
</html>
Loading