Cypress: .should('not.be.visible') fails when elem out of viewport

Created on 6 Nov 2017  路  14Comments  路  Source: cypress-io/cypress

Current behavior:

cy.get().should('not.be.visible')

fails even when DOM element is not in viewport.

It seems I'm not the only one reporting this behavior.

Desired behavior:

Acc to doc, only actionable commands should autoscroll DOM to viewport.

How to reproduce:

// ensure small viewport
cy.viewport( 999, 200 );
// ensure scrollbar is disabled, for good measure (though it doesn't seem Cypress cares)
cy.window().then( window => {
    window.$("body").css("overflow-y", "hidden");
});
// manually test for whether elem is out of viewport -- PASSES
cy.get(".elem").first().then( $el => {

    const bottom = Cypress.$( cy.state("window") ).height();
    const rect = $el[0].getBoundingClientRect();

    expect( rect.top ).to.be.greaterThan( bottom );
    expect( rect.bottom ).to.be.greaterThan( bottom );
    expect( rect.top ).to.be.greaterThan( bottom );
    expect( rect.bottom ).to.be.greaterThan( bottom );
});
// FAILS
cy.get(".elem").first().should("not.be.visible");
  • Operating System: win7x64
  • Cypress Version: 1.0.3
visibility 馃憗

Most helpful comment

For readers in the future, you can add custom commands in cypress/support/commands.js like so:

Cypress.Commands.add('isNotInViewport', element => {
  cy.get(element).then($el => {
    const bottom = Cypress.$(cy.state('window')).height()
    const rect = $el[0].getBoundingClientRect()

    expect(rect.top).to.be.greaterThan(bottom)
    expect(rect.bottom).to.be.greaterThan(bottom)
    expect(rect.top).to.be.greaterThan(bottom)
    expect(rect.bottom).to.be.greaterThan(bottom)
  })
})

Cypress.Commands.add('isInViewport', element => {
  cy.get(element).then($el => {
    const bottom = Cypress.$(cy.state('window')).height()
    const rect = $el[0].getBoundingClientRect()

    expect(rect.top).not.to.be.greaterThan(bottom)
    expect(rect.bottom).not.to.be.greaterThan(bottom)
    expect(rect.top).not.to.be.greaterThan(bottom)
    expect(rect.bottom).not.to.be.greaterThan(bottom)
  })
})

and then in your tests use it like so:

    cy.isNotInViewport('[data-cy=some-invisible-element]')
    cy.isInViewport('[data-cy=some-visible-element]')

All 14 comments

I don't believe this is a bug. We wrote the visibility calculations to take into account elements outside of the viewport.

What I mean is - an element is considered visible if the user in could in any way interact with it - even if they needed to scroll to it.

The reason this rule has to be in place is because scrolling is a mutation. If Cypress first attempted to scroll elements on every single be.visible assertion it could have dramatic side effects that can cause all kinds of problems.

Visibility is simply - is the element capable of being seen by the user? Yes? Visible.

This is the visibility logic in our code, in case you want to investigate before we are able to: https://github.com/cypress-io/cypress/blob/code-of-conduct/packages/driver/src/dom/visibility.coffee#L17

Yes, what @brian-mann explains above is actually true. The example in the kitchen sink works because we take into account elements being clipped by a parent container, but we don't take into account the viewport size when calculating visibility. It's complicated logic.

What you are doing above is essentially what you should continue doing, writing the code to manually check if the element is visible within the viewport.

There are a few exceptions to the rules I listed above. If the element could be clipped by a parent in any capacity, then we check to see if this is currently the case.

In those situations you may need to scroll an element into view first before asserting on its visibility.

Right. Imagine a scenario where this wasn't the case.

You would virtually always need to first scrollIntoView prior to all assertions because controlling the users viewport is needlessly complex.

The vast majority of the time, probably in the 99% percentile range - all you care about is whether or not the element could in fact be seen by the user in some natural capacity.

Yea, that's what I've thought and been relying on (current behavior).. I just misunderstood @jennifer-shehane in the chat and thought it wasn't a feature to begin with.

What's puzzling, is why the test code works at all, considering that visible assertion, taken from chai-jquery#visible should just be $.fn.is(':visible'), which doesn't take element's viewport/boundingBox into account at all. Is it because you're re-implemented the chai assertion (I think you mentioned that somewhere) and diverged from what the assertion does?

Correct. We diverged it completely and use our own algorithm. Then we stiched together the assertion to be our definition of visibility.

https://github.com/cypress-io/cypress/blob/develop/packages/driver/src/config/jquery.coffee#L8

Yea, maybe assertions doc update is in order (maybe you've already mentioned it's planned, don't remember).

For readers in the future, you can add custom commands in cypress/support/commands.js like so:

Cypress.Commands.add('isNotInViewport', element => {
  cy.get(element).then($el => {
    const bottom = Cypress.$(cy.state('window')).height()
    const rect = $el[0].getBoundingClientRect()

    expect(rect.top).to.be.greaterThan(bottom)
    expect(rect.bottom).to.be.greaterThan(bottom)
    expect(rect.top).to.be.greaterThan(bottom)
    expect(rect.bottom).to.be.greaterThan(bottom)
  })
})

Cypress.Commands.add('isInViewport', element => {
  cy.get(element).then($el => {
    const bottom = Cypress.$(cy.state('window')).height()
    const rect = $el[0].getBoundingClientRect()

    expect(rect.top).not.to.be.greaterThan(bottom)
    expect(rect.bottom).not.to.be.greaterThan(bottom)
    expect(rect.top).not.to.be.greaterThan(bottom)
    expect(rect.bottom).not.to.be.greaterThan(bottom)
  })
})

and then in your tests use it like so:

    cy.isNotInViewport('[data-cy=some-invisible-element]')
    cy.isInViewport('[data-cy=some-visible-element]')

For readers in the future, you can add custom commands in cypress/support/commands.js like so:

Cypress.Commands.add('isNotInViewport', element => {
  cy.get(element).then($el => {
    const bottom = Cypress.$(cy.state('window')).height()
    const rect = $el[0].getBoundingClientRect()

    expect(rect.top).to.be.greaterThan(bottom)
    expect(rect.bottom).to.be.greaterThan(bottom)
    expect(rect.top).to.be.greaterThan(bottom)
    expect(rect.bottom).to.be.greaterThan(bottom)
  })
})

Cypress.Commands.add('isInViewport', element => {
  cy.get(element).then($el => {
    const bottom = Cypress.$(cy.state('window')).height()
    const rect = $el[0].getBoundingClientRect()

    expect(rect.top).not.to.be.greaterThan(bottom)
    expect(rect.bottom).not.to.be.greaterThan(bottom)
    expect(rect.top).not.to.be.greaterThan(bottom)
    expect(rect.bottom).not.to.be.greaterThan(bottom)
  })
})

and then in your tests use it like so:

    cy.isNotInViewport('[data-cy=some-invisible-element]')
    cy.isInViewport('[data-cy=some-visible-element]')

In my particular use case, the assertion fails since the element comes into view after a smooth scroll. As a solution, I replace .then with .should (after cy.get(element)) to allow retries.

@shreyansqt Thanks for your code

Updated it to be chain-able

//e.g
cy.get('button').isInViewPort().click()

// Command
Cypress.Commands.add('isInViewport', { prevSubject: true },(subject) => {
    const bottom = Cypress.$(cy.state('window')).height();
    const rect = subject[0].getBoundingClientRect();

    expect(rect.top).not.to.be.greaterThan(bottom);
    expect(rect.bottom).not.to.be.greaterThan(bottom);

    return subject;
});

Inspired by previous very helpful commands I wrote a command which you can use to check position of element in all directions.

My case was testing a "caroussel" on mobile where you can slide through options left or right. Needed to verify it was not placing the options in a vertical list. However, the elements would be visible for a few pixels on the edges. That's why my command checks the position of the element's center in relation to viewport instead of the edges.

// Positions: inside, above, below, left, right
cy.get('center').positionToViewport('inside').click()
cy.get('left').positionToViewport('left')
cy.get('below').positionToViewport('below')

// Command
Cypress.Commands.add('positionToViewport', { prevSubject: true }, (element, position) => {
    cy.get(element).should($el => {
        const height = Cypress.$(cy.state('window')).height()
        const width = Cypress.$(cy.state('window')).width()
        const rect = $el[0].getBoundingClientRect()

        if(position == 'inside'){
            expect((rect.top + (rect.height/2)), 'element center not above viewport').to.be.greaterThan(0)
            expect((rect.top + (rect.height/2)), 'element center not below viewport').to.be.lessThan(height)
            expect((rect.left + (rect.width/2)), 'element center not left of viewport').to.be.greaterThan(0)
            expect((rect.left, + (rect.width/2)), 'element center not right of viewport').to.be.lessThan(width)
        }else if(position == 'above'){
            expect((rect.top + (rect.height/2)), 'element center above viewport').to.be.lessThan(0)
        }else if(position == 'below'){
            expect((rect.top + (rect.height/2)), 'element center below viewport').to.be.greaterThan(height)
        }else if(position == 'left'){
            expect((rect.left + (rect.width/2)), 'element center left of viewport').to.be.lessThan(0)
        }else if(position == 'right'){
            expect((rect.left + (rect.width/2)), 'element center right of viewport').to.be.greaterThan(width)
        }
    })
})

Any comments or improvements are welcome ofcourse.

Inspired by @Whassup, I rewrote the command as an assertion. Simply paste the following into a cypress/support/assertions.js file and do import './assertions'; in your cupress/support/index.js file.

const isInViewport = (_chai, utils) => {
  function assertIsInViewport(options) {

    const subject = this._obj;

    const bottom = Cypress.$(cy.state('window')).height();
    const rect = subject[0].getBoundingClientRect();

    this.assert(
      rect.top < bottom && rect.bottom < bottom,
      "expected #{this} to be in viewport",
      "expected #{this} to not be in viewport",
      this._obj
    )
  }

  _chai.Assertion.addMethod('inViewport', assertIsInViewport)
};

chai.use(isInViewport);

Usage:

cy.get("button").should("be.inViewport");

Thanks @thomaseizinger

For my use case I changed the behavior so that also the current scroll-position is taken into account and the element is seen as in viewport, as long as it could partly be seen. Either the rect.top or rect.bottom value must be in the viewport.

```js script
const isInViewport = (_chai, utils) => {
function assertIsInViewport(options) {
const subject = this._obj

    const windowHeight = Cypress.$(cy.state('window')).height()
    const bottomOfCurrentViewport = windowHeight
    const rect = subject[0].getBoundingClientRect()

    this.assert(
        (rect.top > 0 && rect.top < bottomOfCurrentViewport) ||
            (rect.bottom > 0 && rect.bottom < bottomOfCurrentViewport),
        'expected #{this} to be in viewport',
        'expected #{this} to not be in viewport',
        subject,
    )
}

_chai.Assertion.addMethod('inViewport', assertIsInViewport)

}

chai.use(isInViewport)
```

Was this page helpful?
0 / 5 - 0 ratings