Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 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
200 changes: 200 additions & 0 deletions packages/driver/cypress/e2e/dom/_TEMP_fast_vis_shadow_playground.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// TEMPORARY playground for poking at experimentalFastVisibility + Shadow DOM.
// Pairs with cypress-io/cypress#33046 / #33737. Delete when no longer needed.
//
// Run in `cypress open` and pick this spec. Each example renders its own DOM
// in the AUT and asserts visibility under both `fast` and `legacy` so you can
// flip between the two and see the difference. The assertions match what
// visibility_shadow_dom.cy.ts asserts; the fixtures are duplicated here so the
// runner shows them in isolation rather than scrolled past in a 60+ test list.
//
// Skipped under CI (`describeIfLocal`) so this exploratory spec doesn't add
// noise to the suite — `visibility_shadow_dom.cy.ts` already provides the
// authoritative coverage.

export {} // make typescript see this as a module

const { $ } = Cypress

const describeIfLocal = Cypress.env('CI') ? describe.skip : describe

const buildAdd = (win: Window) => {
if (!(win.customElements as any).get('playground-host')) {
win.customElements.define('playground-host', class extends win.HTMLElement {
constructor () {
super()
this.attachShadow({ mode: 'open' })
this.style.display = 'block'
}
})
}

return (lightHTML: string, shadowHTML: string, hostSelector: string) => {
const $light = $(lightHTML).appendTo(cy.$$('body'))

$(shadowHTML).appendTo(cy.$$(hostSelector)[0].shadowRoot!)

return $light
}
}

describeIfLocal('fast visibility + Shadow DOM playground', () => {
let add: (lightHTML: string, shadowHTML: string, hostSelector: string) => JQuery<HTMLElement>

for (const mode of ['fast', 'legacy']) {
describe(`${mode} mode`, {
experimentalFastVisibility: mode === 'fast',
}, () => {
beforeEach(() => {
cy.visit('/fixtures/empty.html').then((win) => {
add = buildAdd(win)

// Match visibility_shadow_dom.cy.ts: ensure body is scrollable and
// already scrolled, so layout/scroll quirks surface the way they do
// in real apps.
const $sentinel = $(`<div style='height: 1000px; width: 10px;'></div><div>Should be in view</div>`).appendTo(cy.$$('body'))

$sentinel.get(1).scrollIntoView()
})
})

describe('✅ now works under fast (regressions fixed by #33737)', () => {
it('hides shadow content when its host is `visibility: hidden`', () => {
const $host = add(
`<playground-host id="host-vis-hidden" style="visibility: hidden;"></playground-host>`,
`<button>I should be hidden</button>`,
'#host-vis-hidden',
)

cy.wrap($host).find('button', { includeShadowDom: true }).should('be.hidden')
})

it('shows shadow span at abs(50,50) inside an overflow:hidden 200×200 box (in-bounds, below the fold)', () => {
// This was test 5 — un-skipped by the in-bounds smart-clipping change.
const $light = add(
`<div style='width: 200px; height: 200px; overflow: hidden; position: relative;'>
<div style='position: absolute;'>
<playground-host id="host-in-bounds-below-fold"></playground-host>
</div>
</div>`,
`<span style='position: absolute; left: 50px; top: 50px;'>in bounds</span>`,
'#host-in-bounds-below-fold',
)

cy.wrap($light).find('span', { includeShadowDom: true }).should('be.visible')
})

it('hides shadow content positioned out-of-bounds of an overflow:hidden parent', () => {
// One of the three CI regressions fixed by walking shadow boundaries
// in hasClippingAncestor.
const $light = add(
`<div style='width: 100px; height: 100px; overflow: hidden; position: relative;'>
<playground-host id="host-out-of-bounds-below"></playground-host>
</div>`,
`<span style='position: absolute; width: 100px; height: 100px; bottom: -100px; left: 0;'>clipped, you should not see me</span>`,
'#host-out-of-bounds-below',
)

cy.wrap($light).find('span', { includeShadowDom: true }).should('be.hidden')
})

it('hides shadow content out-of-bounds of an overflow:scroll parent', () => {
// The Firefox-specific CI regression — fixed by including scroll/auto
// in CLIPPING_OVERFLOW.
const $light = add(
`<div style='width: 100px; height: 100px; overflow: scroll; position: relative; top: 700px; left: 700px;'>
<playground-host id="host-overflow-scroll-out-of-bounds"></playground-host>
</div>`,
`<span style='position: absolute; left: 300px; top: 0;'>I am off-screen of an overflow:scroll container</span>`,
'#host-overflow-scroll-out-of-bounds',
)

cy.wrap($light).find('span', { includeShadowDom: true }).should('be.hidden')
})

it('finds shadow content via the shadow-aware ancestor walk (was: el.contains)', () => {
// visibleAtPoint's contains check now uses findParent which crosses
// shadow boundaries. Without this change, fast would have reported
// the button as hidden even when document.elementFromPoint correctly
// returned the host.
const $light = add(
`<playground-host id="host-shadow-aware-contains"></playground-host>`,
`<button style="position: fixed; top: 30px; left: 30px;">I am rendered, please find me</button>`,
'#host-shadow-aware-contains',
)

cy.wrap($light).find('button', { includeShadowDom: true }).should('be.visible')
})
})

describe('⚠️ still skipped under fast (documented divergences)', () => {
// Each of these PASSES under legacy and FAILS under fast for the
// reasons explained in the comment in visibility_shadow_dom.cy.ts.
// Skipped under fast so CI stays green. To watch fast turn red live in
// cypress open, flip `itDivergent` to `it` below for the run.
const itDivergent = mode === 'fast' ? it.skip : it

itDivergent('test 1: shadow underneath wider than its outside cover (cover-width mismatch)', () => {
const $light = add(
`<div>
<playground-host id="host-narrow-cover"></playground-host>
<div style="position: fixed; bottom: 0; left: 0; background: rgba(255, 0, 0, 0.5);">on top</div>
</div>`,
`<div id="inside-underneath" style="position: fixed; bottom: 0; left: 0; background: lightblue;">underneath</div>`,
'#host-narrow-cover',
)

cy.wrap($light).find('#inside-underneath', { includeShadowDom: true }).should('be.hidden')
})

itDivergent('test 3: shadow span with `position: fixed` whose host parent has `pointer-events: none`', () => {
const $light = add(
`<div style="pointer-events: none;">
<playground-host id="host-pe-none-parent"></playground-host>
</div>`,
`<span style="position: fixed; top: 20px;">I am rendered but elementFromPoint skips me</span>`,
'#host-pe-none-parent',
)

cy.wrap($light).find('span', { includeShadowDom: true }).should('be.visible')
})

itDivergent('test 4: shadow span with `pointer-events: none` whose host parent is fixed', () => {
const $light = add(
`<div style="position: fixed; top: 60px;">
<playground-host id="host-pe-none-span"></playground-host>
</div>`,
`<span style="pointer-events: none;">I am rendered but elementFromPoint skips me</span>`,
'#host-pe-none-span',
)

cy.wrap($light).find('span', { includeShadowDom: true }).should('be.visible')
})

itDivergent('test 8: clipping ancestor sits between the subject and its containing block', () => {
// outer (relative) is the abs button's containing block. The
// overflow:auto breaking-container in between would only clip the
// button per a strict descendant-clips reading; legacy's
// canClipContent uses `offsetParent` rules to ignore it.
const $light = add(
`<div style="height: 200px; position: relative; display: flex">
<div style="border: 5px solid red">
<div id="breaking-container" style="overflow: auto; border: 5px solid green">
<div>
<h1>Example</h1>
<playground-host id="host-clipper-between-cb"></playground-host>
</div>
</div>
</div>
</div>`,
`<div style="position: absolute; bottom: 5px">
<button id="visible-button">Try me</button>
</div>`,
'#host-clipper-between-cb',
)

cy.wrap($light).find('#visible-button', { includeShadowDom: true }).should('be.visible')
})
})
})
}
})
Comment thread
cursor[bot] marked this conversation as resolved.
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
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 +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
}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

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