Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature]: Extend isVisible/toBeVisible API to include overlap detection #34778

Open
acsbendi opened this issue Feb 13, 2025 · 5 comments
Open

Comments

@acsbendi
Copy link

acsbendi commented Feb 13, 2025

🚀 Feature Request

Extend the isVisible and toBeVisible calls to have an optional flag checkOverlaps (or enforceNotOverlapped if we want to be more precise), which would check if the element is overlapped by another one and thus not visible. For backward-compatibility, the flag would have a default value of false, in which case isVisible/toBeVisible would work exactly the same way as it does now.

If there is a strong argument for keeping these existing APIs unchanged, a new method could also be introduced (e.g. isNotOverlapped/toBeNotOverlapped).

Here's a possible implementation (does not consider transformations yet) that we use in our codebase to check overlaps.

function isElementNotOverlapped (element: Locator): Promise<boolean> {
  return element.evaluate((el) => {
    // pointer-events: none interferes with elementFromPoint so we temporarily set the property to all
    const originalPointerEvents = el.style.pointerEvents

    el.style.pointerEvents = 'all'
    const rect = el.getBoundingClientRect()

    const getStyleValueAsNumber = (styleProperty: string) => {
      return Number(window.getComputedStyle(el, null).getPropertyValue(styleProperty)
        .replace('px', ''))
    }

    const paddingLeft = getStyleValueAsNumber('padding-left')
    const paddingRight = getStyleValueAsNumber('padding-right')
    const paddingTop = getStyleValueAsNumber('padding-top')
    const paddingBottom = getStyleValueAsNumber('padding-bottom')
    const borderRadiusTopLeft = getStyleValueAsNumber('border-top-left-radius')
    const borderRadiusTopRight = getStyleValueAsNumber('border-top-right-radius')
    const borderRadiusBottomLeft = getStyleValueAsNumber('border-bottom-left-radius')
    const borderRadiusBottomRight = getStyleValueAsNumber('border-bottom-right-radius')

    const isPointVisible = (x: number, y: number) => {
      const elementAtPoint = document.elementFromPoint(x, y)

      return el.contains(elementAtPoint) || elementAtPoint === el
    }

    const pointsToCheckOffset = 2
    const leftEdgeToCheck = rect.left + paddingLeft + pointsToCheckOffset
    const topEdgeToCheck = rect.top + paddingTop + pointsToCheckOffset
    const rightEdgeToCheck = rect.right - paddingRight - pointsToCheckOffset
    const bottomEdgeToCheck = rect.bottom - paddingBottom - pointsToCheckOffset

    const pointsToCheck = [
      // top-left corner
      {x: leftEdgeToCheck + (borderRadiusTopLeft / 3), y: topEdgeToCheck + (borderRadiusTopLeft / 3)},
      // top-right corner
      {x: rightEdgeToCheck - (borderRadiusTopRight / 3), y: topEdgeToCheck + (borderRadiusTopRight / 3)},
      // bottom-left corner
      {x: leftEdgeToCheck + (borderRadiusBottomLeft / 3), y: bottomEdgeToCheck - (borderRadiusBottomLeft / 3)},
      // bottom-right corner
      {x: rightEdgeToCheck - (borderRadiusBottomRight / 3), y: bottomEdgeToCheck - (borderRadiusBottomRight / 3)},
      // center
      {x: rect.left + (rect.width / 2), y: rect.top + (rect.height / 2)}
    ]

    const result = pointsToCheck.every((point) => isPointVisible(point.x, point.y))

    el.style.pointerEvents = originalPointerEvents

    return result
  })

Example

await page.goto('data:text/html,<html><body style="margin: 0;">' +
    '<div id="small-box" style="background-color: green; height: 50px; width: 50px; position: fixed; top: 80px">' +
    '</div><div id="big-box" style="background-color: brown; height: 100px; width: 100px; position: fixed;">' +
    '</div></html>')

const smallBox = page.locator('#small-box')

// Passes
await expect(smallBox).toBeVisible()
// Passes
await expect(smallBox).toBeInViewport()
  
// Does not pass, since overlapped by `#big-box`.
await expect(smallBox).toBeVisible({checkOverlaps: true})

Motivation

As described in this previous issue (as well as this one), isVisible currently does not consider overlaps at all, so if an element is fully behind another one, isVisible may still return true for it. It would be useful to be able to test that an element is actually visible on the screen, since some visual bugs can offer due to overlapping elements and these are currently hard to write tests for using Playwright. Modern web apps often feature a lot of popups, modals etc. that may overlap other content on the screen.

@la-christopher
Copy link

This feature will help in accessibility testing - checking if all the buttons do not overlap when the browser's zoom is set to more than 100% and smaller viewports. 👍

@VGervasio
Copy link

The use of toBeInViewPort() (https://playwright.dev/docs/next/api/class-locatorassertions#locator-assertions-to-be-in-viewport) does not help with that use case?

@acsbendi
Copy link
Author

acsbendi commented Feb 17, 2025

@VGervasio No, viewport does not consider other elements, it only checks whether the element is inside/outside of the screen. I've added that to the example too.

@VGervasio
Copy link

I thought that if the other elements blocked the visibility of the element toBeInViewPort would detect it. Good to know.

@acsbendi
Copy link
Author

@VGervasio At first, I also assumed that, but then I dug a bit deeper, and I found that viewport detection relies on the Intersection Observer API and compares it with the device's viewport. And if I understood correctly, there is no way to achieve real overlap detection using the Intersection Observer API. You can read more about it here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants