diff --git a/packages/driver/cypress/fixtures/sticky-header.html b/packages/driver/cypress/fixtures/sticky-header.html new file mode 100644 index 000000000000..63f891a7ee39 --- /dev/null +++ b/packages/driver/cypress/fixtures/sticky-header.html @@ -0,0 +1,17 @@ + + + +
+
+ +
+

content to scroll to

+ + +
+
+
+ + \ No newline at end of file diff --git a/packages/driver/cypress/integration/commands/actions/check_spec.js b/packages/driver/cypress/integration/commands/actions/check_spec.js index 3b236e383228..a60c9cd10957 100644 --- a/packages/driver/cypress/integration/commands/actions/check_spec.js +++ b/packages/driver/cypress/integration/commands/actions/check_spec.js @@ -204,6 +204,42 @@ describe('src/cy/commands/actions/check', () => { }) }) + it('can specify scrollBehavior bottom in config', { scrollBehavior: 'bottom' }, () => { + cy.get(':checkbox:first').then((el) => { + cy.spy(el[0], 'scrollIntoView') + }) + + cy.get(':checkbox:first').check() + + cy.get(':checkbox:first').then((el) => { + expect(el[0].scrollIntoView).to.be.calledWith({ block: 'end' }) + }) + }) + + it('can specify scrollBehavior center in config', { scrollBehavior: 'center' }, () => { + cy.get(':checkbox:first').then((el) => { + cy.spy(el[0], 'scrollIntoView') + }) + + cy.get(':checkbox:first').check() + + cy.get(':checkbox:first').then((el) => { + expect(el[0].scrollIntoView).to.be.calledWith({ block: 'center' }) + }) + }) + + it('can specify scrollBehavior nearest in config', { scrollBehavior: 'nearest' }, () => { + cy.get(':checkbox:first').then((el) => { + cy.spy(el[0], 'scrollIntoView') + }) + + cy.get(':checkbox:first').check() + + cy.get(':checkbox:first').then((el) => { + expect(el[0].scrollIntoView).to.be.calledWith({ block: 'nearest' }) + }) + }) + it('does not scroll when scrollBehavior is false in config', { scrollBehavior: false }, () => { cy.get(':checkbox:first').scrollIntoView() cy.get(':checkbox:first').then((el) => { @@ -230,6 +266,13 @@ describe('src/cy/commands/actions/check', () => { }) }) + // https://github.com/cypress-io/cypress/issues/4233 + it('can check an element behind a sticky header', () => { + cy.viewport(400, 400) + cy.visit('./fixtures/sticky-header.html') + cy.get(':checkbox:first').check() + }) + it('waits until element is no longer disabled', () => { const chk = $(':checkbox:first').prop('disabled', true) diff --git a/packages/driver/cypress/integration/commands/actions/clear_spec.js b/packages/driver/cypress/integration/commands/actions/clear_spec.js index 145d135d9cb4..5dfab1bb74ae 100644 --- a/packages/driver/cypress/integration/commands/actions/clear_spec.js +++ b/packages/driver/cypress/integration/commands/actions/clear_spec.js @@ -124,6 +124,13 @@ describe('src/cy/commands/actions/type - #clear', () => { }) }) + // https://github.com/cypress-io/cypress/issues/4233 + it('can scroll to an element behind a sticky header', () => { + cy.viewport(400, 400) + cy.visit('./fixtures/sticky-header.html') + cy.get('input:first').clear() + }) + // https://github.com/cypress-io/cypress/issues/5835 it('can force clear when hidden in input', () => { const input = cy.$$('input:first') diff --git a/packages/driver/cypress/integration/commands/actions/click_spec.js b/packages/driver/cypress/integration/commands/actions/click_spec.js index 7e0b0cacf535..e8e0bc7b54f3 100644 --- a/packages/driver/cypress/integration/commands/actions/click_spec.js +++ b/packages/driver/cypress/integration/commands/actions/click_spec.js @@ -1090,6 +1090,13 @@ describe('src/cy/commands/actions/click', () => { cy.get('#overflow-auto-container').contains('quux').click() }) + // https://github.com/cypress-io/cypress/issues/4233 + it('can click an element behind a sticky header', () => { + cy.viewport(400, 400) + cy.visit('./fixtures/sticky-header.html') + cy.get('p').click() + }) + it('does not scroll when being forced', () => { const scrolled = [] @@ -1224,6 +1231,42 @@ describe('src/cy/commands/actions/click', () => { }) }) + it('can specify scrollBehavior bottom in config', { scrollBehavior: 'bottom' }, () => { + cy.get('input:first').then((el) => { + cy.spy(el[0], 'scrollIntoView') + }) + + cy.get('input:first').click() + + cy.get('input:first').then((el) => { + expect(el[0].scrollIntoView).calledWith({ block: 'end' }) + }) + }) + + it('can specify scrollBehavior center in config', { scrollBehavior: 'center' }, () => { + cy.get('input:first').then((el) => { + cy.spy(el[0], 'scrollIntoView') + }) + + cy.get('input:first').click() + + cy.get('input:first').then((el) => { + expect(el[0].scrollIntoView).calledWith({ block: 'center' }) + }) + }) + + it('can specify scrollBehavior nearest in config', { scrollBehavior: 'nearest' }, () => { + cy.get('input:first').then((el) => { + cy.spy(el[0], 'scrollIntoView') + }) + + cy.get('input:first').click() + + cy.get('input:first').then((el) => { + expect(el[0].scrollIntoView).calledWith({ block: 'nearest' }) + }) + }) + it('does not scroll when scrollBehavior is false in config', { scrollBehavior: false }, () => { cy.get('input:first').then((el) => { cy.spy(el[0], 'scrollIntoView') @@ -2155,7 +2198,8 @@ describe('src/cy/commands/actions/click', () => { cy.on('fail', (err) => { expect(this.logs.length).eq(2) expect(err.message).not.to.contain('CSS property: `opacity: 0`') - expect(err.message).to.contain('`cy.click()` failed because this element is not visible') + expect(err.message).to.contain('`cy.click()` failed because this element') + expect(err.message).to.contain('is being covered by another element') done() }) @@ -2282,17 +2326,10 @@ describe('src/cy/commands/actions/click', () => { expect(lastLog.get('snapshots')[0].name).to.eq('before') expect(lastLog.get('snapshots')[1]).to.be.an('object') expect(lastLog.get('snapshots')[1].name).to.eq('after') - expect(err.message).to.include('`cy.click()` failed because this element is not visible:') - expect(err.message).to.include('>button ...') - expect(err.message).to.include('`` is not visible because it has CSS property: `position: fixed` and it\'s being covered') - expect(err.message).to.include('>span on...') + expect(err.message).to.include('`cy.click()` failed because this element:') + expect(err.message).to.include('is being covered by another element:') expect(err.docsUrl).to.eq('https://on.cypress.io/element-cannot-be-interacted-with') - const console = lastLog.invoke('consoleProps') - - expect(console['Tried to Click']).to.be.undefined - expect(console['But its Covered By']).to.be.undefined - done() }) diff --git a/packages/driver/cypress/integration/commands/actions/trigger_spec.js b/packages/driver/cypress/integration/commands/actions/trigger_spec.js index 7bda43e1a03a..98e483876923 100644 --- a/packages/driver/cypress/integration/commands/actions/trigger_spec.js +++ b/packages/driver/cypress/integration/commands/actions/trigger_spec.js @@ -654,6 +654,42 @@ describe('src/cy/commands/actions/trigger', () => { }) }) + it('can specify scrollBehavior bottom in config', { scrollBehavior: 'bottom' }, () => { + cy.get('button:first').then((el) => { + cy.spy(el[0], 'scrollIntoView') + }) + + cy.get('button:first').trigger('mouseover') + + cy.get('button:first').then((el) => { + expect(el[0].scrollIntoView).to.be.calledWith({ block: 'end' }) + }) + }) + + it('can specify scrollBehavior center in config', { scrollBehavior: 'center' }, () => { + cy.get('button:first').then((el) => { + cy.spy(el[0], 'scrollIntoView') + }) + + cy.get('button:first').trigger('mouseover') + + cy.get('button:first').then((el) => { + expect(el[0].scrollIntoView).to.be.calledWith({ block: 'center' }) + }) + }) + + it('can specify scrollBehavior nearest in config', { scrollBehavior: 'nearest' }, () => { + cy.get('button:first').then((el) => { + cy.spy(el[0], 'scrollIntoView') + }) + + cy.get('button:first').trigger('mouseover') + + cy.get('button:first').then((el) => { + expect(el[0].scrollIntoView).to.be.calledWith({ block: 'nearest' }) + }) + }) + it('does not scroll when scrollBehavior is false in config', { scrollBehavior: false }, () => { cy.scrollTo('top') cy.get('button:first').then((el) => { @@ -680,6 +716,13 @@ describe('src/cy/commands/actions/trigger', () => { }) }) + // https://github.com/cypress-io/cypress/issues/4233 + it('can check an element behind a sticky header', () => { + cy.viewport(400, 400) + cy.visit('./fixtures/sticky-header.html') + cy.get('p').trigger('mouseover') + }) + it('errors when scrollBehavior is false and element is out of view and is clicked', (done) => { cy.scrollTo('top') @@ -1046,7 +1089,8 @@ describe('src/cy/commands/actions/trigger', () => { cy.on('fail', (err) => { expect(this.logs.length).eq(2) expect(err.message).not.to.contain('CSS property: `opacity: 0`') - expect(err.message).to.contain('`cy.trigger()` failed because this element is not visible') + expect(err.message).to.contain('`cy.trigger()` failed because this element') + expect(err.message).to.contain('is being covered by another element') done() }) diff --git a/packages/driver/cypress/integration/commands/actions/type_spec.js b/packages/driver/cypress/integration/commands/actions/type_spec.js index 9d338c27eabd..99a2cacbd6e3 100644 --- a/packages/driver/cypress/integration/commands/actions/type_spec.js +++ b/packages/driver/cypress/integration/commands/actions/type_spec.js @@ -408,6 +408,42 @@ describe('src/cy/commands/actions/type - #type', () => { }) }) + it('can specify scrollBehavior bottom in config', { scrollBehavior: 'bottom' }, () => { + cy.get(':text:first').then((el) => { + cy.spy(el[0], 'scrollIntoView') + }) + + cy.get(':text:first').type('foo') + + cy.get(':text:first').then((el) => { + expect(el[0].scrollIntoView).to.be.calledWith({ block: 'end' }) + }) + }) + + it('can specify scrollBehavior center in config', { scrollBehavior: 'center' }, () => { + cy.get(':text:first').then((el) => { + cy.spy(el[0], 'scrollIntoView') + }) + + cy.get(':text:first').type('foo') + + cy.get(':text:first').then((el) => { + expect(el[0].scrollIntoView).to.be.calledWith({ block: 'center' }) + }) + }) + + it('can specify scrollBehavior nearest in config', { scrollBehavior: 'nearest' }, () => { + cy.get(':text:first').then((el) => { + cy.spy(el[0], 'scrollIntoView') + }) + + cy.get(':text:first').type('foo') + + cy.get(':text:first').then((el) => { + expect(el[0].scrollIntoView).to.be.calledWith({ block: 'nearest' }) + }) + }) + it('does not scroll when scrollBehavior is false in config', { scrollBehavior: false }, () => { cy.get(':text:first').then((el) => { cy.spy(el[0], 'scrollIntoView') @@ -432,6 +468,13 @@ describe('src/cy/commands/actions/type - #type', () => { }) }) + // https://github.com/cypress-io/cypress/issues/4233 + it('can scroll to an element behind a sticky header', () => { + cy.viewport(400, 400) + cy.visit('./fixtures/sticky-header.html') + cy.get('input:first').type('foo') + }) + it('errors when scrollBehavior is false and element is out of view and is clicked', (done) => { cy.on('fail', (err) => { expect(err.message).to.include('`cy.type()` failed because the center of this element is hidden from view') diff --git a/packages/driver/src/cy/actionability.ts b/packages/driver/src/cy/actionability.ts index ca0d16eb4eca..ee9b86d0396c 100644 --- a/packages/driver/src/cy/actionability.ts +++ b/packages/driver/src/cy/actionability.ts @@ -288,8 +288,9 @@ const ensureNotAnimating = function (cy, $el, coordsHistory, animationDistanceTh cy.ensureElementIsNotAnimating($el, coordsHistory, animationDistanceThreshold) } -const verify = function (cy, $el, options, callbacks) { +const verify = function (cy, $el, config, options, callbacks) { _.defaults(options, { + scrollBehavior: config('scrollBehavior'), ensure: { position: true, visibility: true, @@ -381,9 +382,11 @@ const verify = function (cy, $el, options, callbacks) { } } - // ensure its visible if (options.ensure.visibility) { - cy.ensureVisibility($el, _log) + // ensure element is visible but do not check if hidden by ancestors + // until nudging algorithm occurs + // https://whimsical.com/actionability-J38eY9K2Y3vA6uCMWtmLVA + cy.ensureStrictVisibility($el, _log) } if (options.ensure.notReadonly) { @@ -419,6 +422,7 @@ const verify = function (cy, $el, options, callbacks) { // this calculation is relative from the viewport so we // only care about fromElViewport coords $elAtCoords = options.ensure.notCovered && ensureElIsNotCovered(cy, win, $el, coords.fromElViewport, options, _log, onScroll) + cy.ensureNotHiddenByAncestors($el, _log) } // pass our final object into onReady diff --git a/packages/driver/src/cy/commands/actions/click.ts b/packages/driver/src/cy/commands/actions/click.ts index 07bf7cd25c22..796b50752cba 100644 --- a/packages/driver/src/cy/commands/actions/click.ts +++ b/packages/driver/src/cy/commands/actions/click.ts @@ -65,7 +65,6 @@ export default (Commands, Cypress, cy, state, config) => { errorOnSelect: true, waitForAnimations: config('waitForAnimations'), animationDistanceThreshold: config('animationDistanceThreshold'), - scrollBehavior: config('scrollBehavior'), ctrlKey: false, controlKey: false, altKey: false, @@ -183,13 +182,15 @@ export default (Commands, Cypress, cy, state, config) => { // properties like `total` and `_retries` are mutated by // $actionability.verify and retrying, but each click should // have its own full timeout - const individualOptions = { ... options } + const individualOptions = { + ...options, + } // must use callbacks here instead of .then() // because we're issuing the clicks synchronously // once we establish the coordinates and the element // passes all of the internal checks - return $actionability.verify(cy, $el, individualOptions, { + return $actionability.verify(cy, $el, config, individualOptions, { onScroll ($el, type) { return Cypress.action('cy:scrolled', $el, type) }, diff --git a/packages/driver/src/cy/commands/actions/trigger.ts b/packages/driver/src/cy/commands/actions/trigger.ts index e0bdf26aa2b6..5b6b448134f5 100644 --- a/packages/driver/src/cy/commands/actions/trigger.ts +++ b/packages/driver/src/cy/commands/actions/trigger.ts @@ -56,7 +56,6 @@ export default (Commands, Cypress, cy, state, config) => { y, waitForAnimations: config('waitForAnimations'), animationDistanceThreshold: config('animationDistanceThreshold'), - scrollBehavior: config('scrollBehavior'), }) if ($dom.isWindow(options.$el)) { @@ -112,7 +111,7 @@ export default (Commands, Cypress, cy, state, config) => { return dispatch(subject, state('window'), eventName, eventOptions) } - return $actionability.verify(cy, subject, options, { + return $actionability.verify(cy, subject, config, options, { onScroll ($el, type) { Cypress.action('cy:scrolled', $el, type) }, diff --git a/packages/driver/src/cy/commands/actions/type.ts b/packages/driver/src/cy/commands/actions/type.ts index 53bac165685c..d6d18c9445de 100644 --- a/packages/driver/src/cy/commands/actions/type.ts +++ b/packages/driver/src/cy/commands/actions/type.ts @@ -31,7 +31,6 @@ export default function (Commands, Cypress, cy, state, config) { parseSpecialCharSequences: true, waitForAnimations: config('waitForAnimations'), animationDistanceThreshold: config('animationDistanceThreshold'), - scrollBehavior: config('scrollBehavior'), }) if (options.log) { @@ -415,7 +414,7 @@ export default function (Commands, Cypress, cy, state, config) { } } - return $actionability.verify(cy, options.$el, options, { + return $actionability.verify(cy, options.$el, config, options, { onScroll ($el, type) { return Cypress.action('cy:scrolled', $el, type) }, @@ -442,6 +441,7 @@ export default function (Commands, Cypress, cy, state, config) { timeout: options.timeout, interval: options.interval, errorOnSelect: false, + scrollBehavior: options.scrollBehavior, }) .then(() => { let activeElement = $elements.getActiveElByDocument($elToClick) @@ -502,7 +502,6 @@ export default function (Commands, Cypress, cy, state, config) { force: false, waitForAnimations: config('waitForAnimations'), animationDistanceThreshold: config('animationDistanceThreshold'), - scrollBehavior: config('scrollBehavior'), }) // blow up if any member of the subject @@ -569,7 +568,7 @@ export default function (Commands, Cypress, cy, state, config) { notReadonly: true, } - return $actionability.verify(cy, $el, options, { + return $actionability.verify(cy, $el, config, options, { onScroll ($el, type) { return Cypress.action('cy:scrolled', $el, type) }, diff --git a/packages/driver/src/cy/ensures.ts b/packages/driver/src/cy/ensures.ts index 5af8d946c62a..e3976e11c4e6 100644 --- a/packages/driver/src/cy/ensures.ts +++ b/packages/driver/src/cy/ensures.ts @@ -158,9 +158,9 @@ export default { } } - const ensureVisibility = (subject, onFail) => { + const runVisibilityCheck = (subject, onFail, method) => { if (subject.length !== subject.filter(function () { - return !$dom.isHidden(this, 'isVisible()', { checkOpacity: false }) + return !method(this, 'isVisible()', { checkOpacity: false }) }).length) { const cmd = state('current').get('name') const reason = $dom.getReasonIsHidden(subject, { checkOpacity: false }) @@ -173,6 +173,18 @@ export default { } } + const ensureVisibility = (subject, onFail) => { + return runVisibilityCheck(subject, onFail, $dom.isHidden) + } + + const ensureStrictVisibility = (subject, onFail) => { + return runVisibilityCheck(subject, onFail, $dom.isStrictlyHidden) + } + + const ensureNotHiddenByAncestors = (subject, onFail) => { + return runVisibilityCheck(subject, onFail, $dom.isHiddenByAncestors) + } + const ensureAttached = (subject, name, onFail) => { if ($dom.isDetached(subject)) { const current = state('current') @@ -399,6 +411,10 @@ export default { ensureVisibility, + ensureStrictVisibility, + + ensureNotHiddenByAncestors, + ensureExistence, ensureElExistence, diff --git a/packages/driver/src/cypress/cy.ts b/packages/driver/src/cypress/cy.ts index b25f9d20420e..0a43e12e3fab 100644 --- a/packages/driver/src/cypress/cy.ts +++ b/packages/driver/src/cypress/cy.ts @@ -619,6 +619,8 @@ export default { ensureElExistence: ensures.ensureElExistence, ensureElDoesNotHaveCSS: ensures.ensureElDoesNotHaveCSS, ensureVisibility: ensures.ensureVisibility, + ensureStrictVisibility: ensures.ensureStrictVisibility, + ensureNotHiddenByAncestors: ensures.ensureNotHiddenByAncestors, ensureDescendents: ensures.ensureDescendents, ensureNotReadonly: ensures.ensureNotReadonly, ensureNotDisabled: ensures.ensureNotDisabled, diff --git a/packages/driver/src/dom/index.ts b/packages/driver/src/dom/index.ts index c27d31ef03e0..066afc89b344 100644 --- a/packages/driver/src/dom/index.ts +++ b/packages/driver/src/dom/index.ts @@ -9,7 +9,7 @@ import $visibility from './visibility' const { isWindow, getWindowByElement } = $window const { isDocument, getDocumentFromElement } = $document const { wrap, unwrap, isJquery, query } = $jquery -const { isVisible, isHidden, getReasonIsHidden, isW3CRendered, isW3CFocusable } = $visibility +const { isVisible, isHidden, isStrictlyHidden, isHiddenByAncestors, getReasonIsHidden, isW3CRendered, isW3CFocusable } = $visibility const { isInputType, isFocusable, isElement, isScrollable, isFocused, stringify, getElements, getContainsSelector, getFirstDeepestElement, isDetached, isAttached, isTextLike, isSelector, isDescendent, getFirstFixedOrStickyPositionParent, getFirstStickyPositionParent, getFirstScrollableParent, isUndefinedOrHTMLBodyDoc, elementFromPoint, getParent, findAllShadowRoots, isWithinShadowRoot, getHostContenteditable } = $elements const { getCoordsByPosition, getElementPositioning, getElementCoordinatesByPosition, getElementAtPointFromViewport, getElementCoordinatesByPositionRelativeToXY } = $coordinates const { getSelectionBounds } = $selection @@ -34,6 +34,8 @@ export default { isInputType, isVisible, isHidden, + isStrictlyHidden, + isHiddenByAncestors, isFocusable, isTextLike, isScrollable, diff --git a/packages/driver/src/dom/visibility.ts b/packages/driver/src/dom/visibility.ts index bd8d1a1b5038..9a1be1ac7f26 100644 --- a/packages/driver/src/dom/visibility.ts +++ b/packages/driver/src/dom/visibility.ts @@ -11,7 +11,7 @@ const fixedOrAbsoluteRe = /(fixed|absolute)/ const OVERFLOW_PROPS = ['hidden', 'scroll', 'auto'] -export const isVisible = (el) => { +const isVisible = (el) => { return !isHidden(el, 'isVisible()') } @@ -22,11 +22,22 @@ const { wrap } = $jquery // because of circular references // the ignoreOpacity option exists for checking actionability // as elements with `opacity: 0` are hidden yet actionable -export const isHidden = (el, methodName = 'isHidden()', options = { checkOpacity: true }) => { +const isHidden = (el, methodName = 'isHidden()', options = { checkOpacity: true }) => { + if (isStrictlyHidden(el, methodName, options, isHidden)) { + return true + } + + return isHiddenByAncestors(el, methodName, options) +} + +const ensureEl = (el, methodName) => { if (!isElement(el)) { throw new Error(`\`Cypress.dom.${methodName}\` failed because it requires a DOM element. The subject received was: \`${el}\``) } +} +const isStrictlyHidden = (el, methodName = 'isStrictlyHidden()', options = { checkOpacity: true }, recurse?) => { + ensureEl(el, methodName) const $el = $jquery.wrap(el) // the body and html are always visible @@ -49,7 +60,7 @@ export const isHidden = (el, methodName = 'isHidden()', options = { checkOpacity // in which case it will fall through to regular visibility logic if ($select && $select.length) { // if the select is hidden, the options in it are visible too - return isHidden($select[0], methodName) + return recurse ? recurse($select[0], methodName, options) : isStrictlyHidden($select[0], methodName, options) } } @@ -84,6 +95,13 @@ export const isHidden = (el, methodName = 'isHidden()', options = { checkOpacity return true } + return false +} + +const isHiddenByAncestors = (el, methodName = 'isHiddenByAncestors()', options = { checkOpacity: true }) => { + ensureEl(el, methodName) + const $el = $jquery.wrap(el) + // we do some calculations taking into account the parents // to see if its hidden by a parent if (elIsHiddenByAncestors($el, options.checkOpacity)) { @@ -537,5 +555,5 @@ export const getReasonIsHidden = function ($el, options = { checkOpacity: true } /* eslint-enable no-cond-assign */ export default { - isVisible, isHidden, getReasonIsHidden, isW3CFocusable, isW3CRendered, + isVisible, isHidden, isStrictlyHidden, isHiddenByAncestors, getReasonIsHidden, isW3CFocusable, isW3CRendered, }