How to implement drag and drop in cypress test?
I've written up a small example for how to implement drag and drop.
It works by adding a dragTo
command like so:
/// <reference types="cypress"/>
it('works', () => {
cy.visit('https://angular-oxkc7l-zirwfs.stackblitz.io/')
cy.contains('To do', { timeout: 15000 }) // ensure page is loaded -__-
const item = '.example-box:not(.cdk-drag-placeholder)'
cy.get('#cdk-drop-list-1').children(item).should('have.length', 5)
cy.get('.example-box:contains("Get to work")').dragTo('.example-box:contains("Get up")')
cy.get('#cdk-drop-list-1').children(item).should('have.length', 6)
// interpolates 10 extra mousemove events on the way
cy.get('#cdk-drop-list-0').dragTo('#cdk-drop-list-1', { steps: 10 })
cy.get('#cdk-drop-list-1').children(item).should('have.length', 7)
// sets steps >= 10
cy.get('#cdk-drop-list-0').dragTo('#cdk-drop-list-1', { smooth: true })
cy.get('#cdk-drop-list-1').children(item).should('have.length', 8)
cy.get('#cdk-drop-list-0').dragTo('#cdk-drop-list-1')
cy.get('#cdk-drop-list-1').children(item).should('have.length', 9)
})
To add it, try putting this in your support/index.js
or pasting it at the bottom of a spec file (warning: poor code quality):
const getCoords = ($el) => {
const domRect = $el[0].getBoundingClientRect()
const coords = { x: domRect.left + (domRect.width / 2 || 0), y: domRect.top + (domRect.height / 2 || 0) }
return coords
}
const dragTo = (subject, to, opts) => {
opts = Cypress._.defaults(opts, {
// delay inbetween steps
delay: 0,
// interpolation between coords
steps: 0,
// >=10 steps
smooth: false,
})
if (opts.smooth) {
opts.steps = Math.max(opts.steps, 10)
}
const win = subject[0].ownerDocument.defaultView
const elFromCoords = (coords) => win.document.elementFromPoint(coords.x, coords.y)
const winMouseEvent = win.MouseEvent
const send = (type, coords, el) => {
el = el || elFromCoords(coords)
el.dispatchEvent(
new winMouseEvent(type, Object.assign({}, { clientX: coords.x, clientY: coords.y }, { bubbles: true, cancelable: true }))
)
}
const toSel = to
function drag (from, to, steps = 1) {
const fromEl = elFromCoords(from)
const _log = Cypress.log({
$el: fromEl,
name: 'drag to',
message: toSel,
})
_log.snapshot('before', { next: 'after', at: 0 })
_log.set({ coords: to })
send('mouseover', from, fromEl)
send('mousedown', from, fromEl)
cy.then(() => {
return Cypress.Promise.try(() => {
if (steps > 0) {
const dx = (to.x - from.x) / steps
const dy = (to.y - from.y) / steps
return Cypress.Promise.map(Array(steps).fill(), (v, i) => {
i = steps - 1 - i
let _to = {
x: from.x + dx * (i),
y: from.y + dy * (i),
}
send('mousemove', _to, fromEl)
return Cypress.Promise.delay(opts.delay)
}, { concurrency: 1 })
}
})
.then(() => {
send('mousemove', to, fromEl)
send('mouseover', to)
send('mousemove', to)
send('mouseup', to)
_log.snapshot('after', { at: 1 }).end()
})
})
}
const $el = subject
const fromCoords = getCoords($el)
const toCoords = getCoords(cy.$$(to))
drag(fromCoords, toCoords, opts.steps)
}
Cypress.Commands.addAll(
{ prevSubject: 'element' },
{
dragTo,
}
)
Dispatching MouseEvents seems to be the only way to test Angular Material drag and drop.
You should also be aware of the following issue, which tests in Protractor but also applies to this Cypress test
CDK DragDrop Regression between 7.0.0-beta.2 and 7.0.0-rc.2: Protractor tests stopped working #13642,
It seems that (for want of a better explanation) an additional nudge is needed on the mousemove.
The steps given as a workaround (Protractor syntax),
private async dragAndDrop ( $element, $destination ) {
await browser.actions().mouseMove( $element ).perform();
await browser.actions().mouseDown( $element ).perform();
await browser.actions().mouseMove( {x: 10, y: 0 } ).perform();
await browser.actions().mouseMove( $destination ).perform();
return browser.actions().mouseUp().perform();
}
can be translated into a Cypress test, the simplest form I found is
it('works (simply)', () => {
const draggable = Cypress.$('#cdk-drop-list-0 > :nth-child(1)')[0] // Pick up this
const droppable = Cypress.$('#cdk-drop-list-1 > :nth-child(4)')[0] // Drop over this
const coords = droppable.getBoundingClientRect()
draggable.dispatchEvent(new MouseEvent('mousedown'));
draggable.dispatchEvent(new MouseEvent('mousemove', {clientX: 10, clientY: 0}));
draggable.dispatchEvent(new MouseEvent('mousemove', {
clientX: coords.x+10,
clientY: coords.y+10 // A few extra pixels to get the ordering right
}));
draggable.dispatchEvent(new MouseEvent('mouseup'));
cy.get('#cdk-drop-list-1').should('contain', 'Get to work');
cy.get('#cdk-drop-list-1 > .cdk-drag').eq(3).should('contain', 'Get to work');
});
Notes
- The problem in the referenced issue is not limited to Protractor. If you remove the first
mousemove
in the Cypress test, it also fails. - The
cy.get(..).trigger()
syntax does not seem to work with Angular, but nativedispatchEvent()
does. - Dragging over a specific element in the target list (as opposed to just dropping on the list) gives precise positioning within the target list.
dragstart, dragend
may not be appropriate for Angular Material, as the code shows the event received is typeCdkDragDrop
rather than a DataTransfer object.- If content is asynchronously fetched, you may have to switch from
Cypress.$(...)
tocy.get(...).then(el => {...})
, to take advantage of cypress' auto retry in commands. - I had to add a 10 second timeout to visit the Stackblitz url.
Async list fetching
If the list is fetched by an async Angular service (httpClient) during component construction, using this in the test
const draggable = Cypress.$('#cdk-drop-list-0 > :nth-child(1)')[0]
will not work, because the nth-child will not be present immediately, only after the fetch completes.
Instead, you can use cy.get()
to provide retries up to a timeout (default 5 seconds).
cy.get('#cdk-drop-list-0 > :nth-child(1)').then(el => {
const draggable = el[0] // Pick up this
cy.get('#cdk-drop-list-1 > :nth-child(4)').then(el => {
const droppable = el[0] // Drop over this
const coords = droppable.getBoundingClientRect()
draggable.dispatchEvent(new MouseEvent('mousemove'));
draggable.dispatchEvent(new MouseEvent('mousedown'));
draggable.dispatchEvent(new MouseEvent('mousemove', {clientX: 10, clientY: 0}));
draggable.dispatchEvent(new MouseEvent('mousemove', {clientX: coords.x+10, clientY: coords.y+10}));
draggable.dispatchEvent(new MouseEvent('mouseup'));
})
cy.get('#cdk-drop-list-1').should('contain', 'Get to work');
cy.get('#cdk-drop-list-1 > .cdk-drag').eq(3).should('contain', 'Get to work');
})
or my preference is to use a 'canary' test to ensure loading is complete, something like
before(() => {
cy.get('#cdk-drop-list-0 > :nth-child(1)') // Canary - wait 5s for data
})
it('should...', () => {
const draggable = Cypress.$('#cdk-drop-list-0 > :nth-child(1)')[0] // Pick up this
const droppable = Cypress.$('#cdk-drop-list-1 > :nth-child(4)')[0] // Drop over this
...
})
Typescript support
Warning - this is a quick hack to get over Typescript compiler problems, and could be improved.
Cast
MouseEvent
to<any>
as per MouseEvent in TypeScript error not matching signatureType return of
getBoundingClientRect()
toClientRect
, and use properties left and top instead of x and y.
const coords: ClientRect = droppable.getBoundingClientRect()
draggable.dispatchEvent(new (<any>MouseEvent)('mousemove'));
draggable.dispatchEvent(new (<any>MouseEvent)('mousedown'));
draggable.dispatchEvent(new (<any>MouseEvent)('mousemove', {clientX: 10.0, clientY: 0.0}));
draggable.dispatchEvent(new (<any>MouseEvent)('mousemove', {clientX: coords.left + 10.0, clientY: coords.top + 10.0}));
draggable.dispatchEvent(new (<any>MouseEvent)('mouseup'));
After a lot of battling, I managed to make the drag and drop work with this:
cy.get('.list .item')
.contains(startpos)
.trigger('dragstart', { dataTransfer: new DataTransfer });
cy.get('.list .item')
.eq(endpos)
.trigger('drop')
.trigger('dragend');
Pretty easy to use.
In case anyone is also struggling with cdkDropListEntered not being triggered, you might want to check if there is any scrolling going on.
Since scrolling is handled by CDK (e.g. https://github.com/angular/components/blob/master/src/cdk/scrolling/viewport-ruler.ts#L131), I had to add the scroll position to any mouse events. The scroll position is computed like this (corresponding to above link):
const win = subject[0].ownerDocument.defaultView;
const window = win;
const document = window.document;
const documentElement = document.documentElement;
const documentRect = documentElement.getBoundingClientRect();
const top =
-documentRect.top ||
document.body.scrollTop ||
window.scrollY ||
documentElement.scrollTop ||
0;
const left =
-documentRect.left ||
document.body.scrollLeft ||
window.scrollX ||
documentElement.scrollLeft ||
0;
where subject is the result yielded e.g. by a cy.get command.