Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
24 changes: 14 additions & 10 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,17 @@ 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 where the fast (multi-sample) and legacy (center-point) algorithms
// diverge for shadow DOM subjects. Fixtures here were authored to legacy
// semantics; tracked as follow-up to https://github.com/cypress-io/cypress/issues/33046.
const itSkipFast = mode === 'fast' ? it.skip : it
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR claims 8 skipped tests but only 5 exist

Medium Severity

The PR description states "8 tests … are scoped-skipped under fast" and expects "78 passing, 8 pending," but only 5 itSkipFast calls exist in the file (lines 248, 262, 276, 303, 612). With 43 tests × 2 modes − 5 skips, the actual result is 81 passing / 5 pending. The "shadow content escaping its host's bounding box" bucket claims 4 tests but only 1 (line 612) uses itSkipFast. If the other 3 tests in that bucket genuinely fail under fast mode, CI will break; if they pass, the PR description is misleading.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5bcc769. Configure here.


beforeEach(() => {
cy.visit('/fixtures/empty.html').then((win) => {
win.customElements.define('shadow-root', class extends win.HTMLElement {
Expand Down Expand Up @@ -233,7 +237,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 +251,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 +265,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 +292,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 @@ -456,7 +460,7 @@ describe('src/cypress/dom/visibility - shadow dom', () => {
cy.wrap($elOutOfPosAbsParentBounds).find('span', { includeShadowDom: true }).should('not.be.visible')
})

it('is visible when parent absolutely positioned and overflow hidden and not out of bounds', () => {
itSkipFast('is visible when parent absolutely positioned and overflow hidden and not out of bounds', () => {
const $elInPosAbsParentsBounds = add(
`<div style='width: 200px; height: 200px; overflow: hidden; position: relative;'>
<div style='position: absolute;'>
Expand Down Expand Up @@ -546,7 +550,7 @@ describe('src/cypress/dom/visibility - shadow dom', () => {
cy.wrap($elIsRelativeAndOutOfBoundsOfAncestorButAncestorShowsOverflow).find('span', { includeShadowDom: true }).should('not.be.hidden')
})

it('is visible when parent inside shadow dom is relatively positioned out of bounds but el is relatively positioned back in bounds', () => {
itSkipFast('is visible when parent inside shadow dom is relatively positioned out of bounds but el is relatively positioned back in bounds', () => {
const $insideParentOutOfBoundsButElInBounds = add(
`<div style='position: relative; padding: 20px;'>
<div style='overflow: hidden;'>
Expand All @@ -563,7 +567,7 @@ describe('src/cypress/dom/visibility - shadow dom', () => {
cy.wrap($insideParentOutOfBoundsButElInBounds).find('span', { includeShadowDom: true }).should('not.be.hidden')
})

it('is visible when parent outside shadow dom is relatively positioned out of bounds but el is relatively positioned back in bounds', () => {
itSkipFast('is visible when parent outside shadow dom is relatively positioned out of bounds but el is relatively positioned back in bounds', () => {
const $outsideParentOutOfBoundsButElInBounds = add(
`<div style='position: relative; padding: 20px;'>
<div style='overflow: hidden;'>
Expand Down Expand Up @@ -597,7 +601,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
32 changes: 25 additions & 7 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 @@ -125,31 +137,37 @@ function isOutsideViewport (el: HTMLElement, rect: DOMRect): boolean {
)
}

const CLIPPING_OVERFLOW = new Set(['hidden', 'clip', 'scroll', 'auto'])
Comment thread
cursor[bot] marked this conversation as resolved.

function hasClippingAncestor (el: HTMLElement, rect: DOMRect): boolean {
const win = el.ownerDocument.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.
// for elements below the fold. Treat `scroll` and `auto` as clipping too: the user has
// not scrolled, so out-of-bounds content is not visible right now and we should not
// surface it programmatically.
const offscreenX = rect.right <= 0 || rect.left >= win.innerWidth
const offscreenY = rect.bottom <= 0 || rect.top >= win.innerHeight

let current: HTMLElement | null = el.parentElement
// Walk via getParentNode so we cross shadow root boundaries — a shadow
// descendant's clipping ancestor often lives in the host's light tree.
let current: HTMLElement | null = getParentNode(el)

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

if (offscreenX && (overflowX === 'hidden' || overflowX === 'clip')) {
if (offscreenX && CLIPPING_OVERFLOW.has(overflowX)) {
return true
}

if (offscreenY && (overflowY === 'hidden' || overflowY === 'clip')) {
if (offscreenY && CLIPPING_OVERFLOW.has(overflowY)) {
return true
}

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

return false
Expand Down