Skip to content
Open
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
26 changes: 19 additions & 7 deletions packages/driver/cypress/e2e/dom/visibility_shadow_dom.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,25 @@ const { $ } = Cypress
describe('src/cypress/dom/visibility - shadow dom', () => {
let add: (el: string, shadowEl: string, rootIdentifier: string) => JQuery<HTMLElement>

// #TODO: support shadow dom in fast visibility algorithm: https://github.com/cypress-io/cypress/issues/33046
const modes = ['legacy']
const modes = ['fast', 'legacy']

for (const mode of modes) {
describe(`${mode}`, {
experimentalFastVisibility: mode === 'fast',
}, () => {
// Tests scoped-skipped under fast where fast and legacy fundamentally differ
// because of fast's reliance on `elementFromPoint`. The corresponding non-shadow
// fixtures either avoid these edge cases or aren't exercised by visibility.cy.ts:
// - cover detection where the cover is narrower than the underneath element
// (fast samples four corners; legacy only the center)
// - `pointer-events: none` on the subject or an ancestor (browsers skip such
// elements in `elementFromPoint`, so fast can never find the subject at any
// sample point — even though the element is rendered)
// - clipping ancestor between the subject and its containing block (legacy's
// `canClipContent` uses `offsetParent` rules to ignore such ancestors)
// Tracked as follow-up to https://github.com/cypress-io/cypress/issues/33046.
const itSkipFast = mode === 'fast' ? it.skip : it

beforeEach(() => {
cy.visit('/fixtures/empty.html').then((win) => {
win.customElements.define('shadow-root', class extends win.HTMLElement {
Expand Down Expand Up @@ -233,7 +245,7 @@ describe('src/cypress/dom/visibility - shadow dom', () => {
cy.wrap($descendentPosFixedInside).find('button', { includeShadowDom: true }).should('not.be.hidden')
})

it('is hidden if position: fixed and covered by element outside of shadow dom', () => {
itSkipFast('is hidden if position: fixed and covered by element outside of shadow dom', () => {
const $coveredUpByOutsidePosFixed = add(
`<div>
<shadow-root id="covered-up-by-outside-pos-fixed"></shadow-root>
Expand All @@ -247,7 +259,7 @@ describe('src/cypress/dom/visibility - shadow dom', () => {
cy.wrap($coveredUpByOutsidePosFixed).find('#inside-underneath', { includeShadowDom: true }).should('not.be.visible')
})

it('is hidden if outside of shadow dom with position: fixed and covered by element inside of shadow dom', () => {
itSkipFast('is hidden if outside of shadow dom with position: fixed and covered by element inside of shadow dom', () => {
const $coveredUpByShadowPosFixed = add(
`<div>
<div id="outside-underneath" style="position: fixed; bottom: 0; left: 0">underneath</div>
Expand All @@ -261,7 +273,7 @@ describe('src/cypress/dom/visibility - shadow dom', () => {
cy.wrap($coveredUpByShadowPosFixed).find('#outside-underneath', { includeShadowDom: true }).should('not.be.visible')
})

it('is visible if position: fixed and parent outside shadow dom has pointer-events: none', () => {
itSkipFast('is visible if position: fixed and parent outside shadow dom has pointer-events: none', () => {
const $parentPointerEventsNone = add(
`<div style="pointer-events: none;">
<shadow-root id="parent-pointer-events-none"></shadow-root>
Expand All @@ -288,7 +300,7 @@ describe('src/cypress/dom/visibility - shadow dom', () => {
cy.wrap($parentPointerEventsNoneCovered).find('span', { includeShadowDom: true }).should('not.be.visible')
})

it('is visible if pointer-events: none and parent outside shadow dom has position: fixed', () => {
itSkipFast('is visible if pointer-events: none and parent outside shadow dom has position: fixed', () => {
const $childPointerEventsNone = add(
`<div style="position: fixed; top: 60px;">
<shadow-root id="child-pointer-events-none-covered"></shadow-root>
Expand Down Expand Up @@ -597,7 +609,7 @@ describe('src/cypress/dom/visibility - shadow dom', () => {
cy.wrap(el).find('#visible-button', { includeShadowDom: true }).should('not.be.hidden')
})

it('is visible when element is relatively positioned and parent element is absolutely positioned and ancestor has overflow auto', function () {
itSkipFast('is visible when element is relatively positioned and parent element is absolutely positioned and ancestor has overflow auto', function () {
const el = add(
`<div style="height: 200px; position: relative; display: flex">
<div style="border: 5px solid red">
Expand Down
74 changes: 56 additions & 18 deletions packages/driver/src/dom/visibility/fastIsHidden.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import $elements from '../elements'
import { memoize } from './memoize'
import { unwrap, wrap, isJquery } from '../jquery'
import { scrollBehaviorOptionsMap } from '../../util/scrollBehavior'
import { getShadowElementFromPoint } from '../elements/shadow'
import { findParent, getParentNode } from '../elements/find'
import Debug from 'debug'

const debug = Debug('cypress:driver:dom:visibility:fastIsHidden')
Expand All @@ -11,11 +13,21 @@ const { isOption, isOptgroup, isBody, isHTML } = $elements
const getBoundingClientRect = memoize((el: HTMLElement) => el.getBoundingClientRect())

const visibleAtPoint = memoize(function (el: HTMLElement, x: number, y: number): boolean {
const elAtPoint = el.ownerDocument.elementFromPoint(x, y)
const lightElAtPoint = el.ownerDocument.elementFromPoint(x, y)

if (!lightElAtPoint) return false

// Pierce nested shadow roots so the comparison reflects what the user actually sees.
const elAtPoint = getShadowElementFromPoint(lightElAtPoint, x, y)

debug('visibleAtPoint', el, elAtPoint)

return Boolean(elAtPoint) && (elAtPoint === el || el.contains(elAtPoint))
if (!elAtPoint) return false

if (elAtPoint === el) return true

// Shadow-aware ancestor walk: findParent crosses shadow boundaries via getRootNode().host.
return findParent(elAtPoint, (parent: HTMLElement) => parent === el ? parent : null) === el
})

export function fastIsHidden (subject: JQuery<HTMLElement> | HTMLElement, options: { checkOpacity: boolean } = { checkOpacity: true }): boolean {
Expand Down Expand Up @@ -58,11 +70,12 @@ export function fastIsHidden (subject: JQuery<HTMLElement> | HTMLElement, option

let boundingRect = getBoundingClientRect(subject)

// Don't scroll if any ancestor clips the subject in the direction it is
// off-screen — `scrollIntoView` would scroll the clipping container (it's
// programmatically scrollable even though it's not user-scrollable) and
// expose content the test author intentionally clipped.
if (isOutsideViewport(subject, boundingRect) && !hasClippingAncestor(subject, boundingRect)) {
// Don't scroll if the subject is out-of-bounds of a clipping ancestor on the
// off-screen axis — `scrollIntoView` would programmatically scroll the
// clipping container and surface content the test author intentionally
// clipped. When the subject is *in-bounds* of its clipping ancestor (just
// below the fold), scrolling is safe and necessary to bring it into view.
if (isOutsideViewport(subject, boundingRect) && !isClippedByAncestor(subject, boundingRect)) {
const scrollBehavior = Cypress.config('scrollBehavior')

if (scrollBehavior !== false) {
Expand Down Expand Up @@ -125,31 +138,56 @@ function isOutsideViewport (el: HTMLElement, rect: DOMRect): boolean {
)
}

function hasClippingAncestor (el: HTMLElement, rect: DOMRect): boolean {
const win = el.ownerDocument.defaultView
const CLIPPING_OVERFLOW = new Set(['hidden', 'clip', 'scroll', 'auto'])
Comment thread
cursor[bot] marked this conversation as resolved.

// True iff some ancestor with clipping `overflow` on the same axis the subject is
// off-screen has the subject *out-of-bounds* — i.e., the subject is intentionally
// clipped from the user's view. Subjects merely below the fold of an in-bounds
// clipping ancestor are not "clipped"; they're just scrolled away.
//
// Walk via getParentNode so the search crosses shadow root boundaries — a shadow
// descendant's clipping ancestor often lives in the host's light tree. Treat
// `scroll` and `auto` as clipping too: the user has not scrolled, so any content
// outside the visible region is hidden right now and should not be surfaced
// programmatically.
function isClippedByAncestor (el: HTMLElement, rect: DOMRect): boolean {
const doc = el.ownerDocument
const win = doc.defaultView

if (!win) return false

// Only ancestors clipping on the off-screen axis matter — e.g. `body { overflow-x: hidden }`
// (a common pattern to suppress horizontal scrollbars) must not block vertical scrolling
// for elements below the fold.
const offscreenX = rect.right <= 0 || rect.left >= win.innerWidth
const offscreenY = rect.bottom <= 0 || rect.top >= win.innerHeight

let current: HTMLElement | null = el.parentElement
let current: HTMLElement | null = getParentNode(el)

while (current) {
// Skip the document's root and body — they are the page's scroll container,
// not real clipping containers. Setting `overflow-x: hidden` on body, for
// example, makes computed `overflow-y` become `auto` per CSS, which would
// otherwise spuriously trip the clipping check on the off-screen axis and
// block legitimate vertical scrolling.
if (current === doc.body || current === doc.documentElement) {
current = getParentNode(current)
continue
}

const { overflowX, overflowY } = win.getComputedStyle(current)
const ancestorRect = current.getBoundingClientRect()

if (offscreenX && (overflowX === 'hidden' || overflowX === 'clip')) {
return true
if (offscreenX && CLIPPING_OVERFLOW.has(overflowX)) {
if (rect.left < ancestorRect.left || rect.right > ancestorRect.right) {
return true
}
}

if (offscreenY && (overflowY === 'hidden' || overflowY === 'clip')) {
return true
if (offscreenY && CLIPPING_OVERFLOW.has(overflowY)) {
if (rect.top < ancestorRect.top || rect.bottom > ancestorRect.bottom) {
return true
}
}

current = current.parentElement
current = getParentNode(current)
}

return false
Expand Down