diff --git a/packages/driver/cypress/e2e/dom/_TEMP_fast_vis_shadow_playground.cy.ts b/packages/driver/cypress/e2e/dom/_TEMP_fast_vis_shadow_playground.cy.ts new file mode 100644 index 0000000000..63b074dbbe --- /dev/null +++ b/packages/driver/cypress/e2e/dom/_TEMP_fast_vis_shadow_playground.cy.ts @@ -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 + + 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 = $(`
Should be in view
`).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( + ``, + ``, + '#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( + `
+
+ +
+
`, + `in bounds`, + '#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( + `
+ +
`, + `clipped, you should not see me`, + '#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( + `
+ +
`, + `I am off-screen of an overflow:scroll container`, + '#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( + ``, + ``, + '#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( + `
+ +
on top
+
`, + `
underneath
`, + '#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( + `
+ +
`, + `I am rendered but elementFromPoint skips me`, + '#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( + `
+ +
`, + `I am rendered but elementFromPoint skips me`, + '#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( + `
+
+
+
+

Example

+ +
+
+
+
`, + `
+ +
`, + '#host-clipper-between-cb', + ) + + cy.wrap($light).find('#visible-button', { includeShadowDom: true }).should('be.visible') + }) + }) + }) + } +}) diff --git a/packages/driver/cypress/e2e/dom/visibility_shadow_dom.cy.ts b/packages/driver/cypress/e2e/dom/visibility_shadow_dom.cy.ts index aa2d2d23af..08a9ca8854 100644 --- a/packages/driver/cypress/e2e/dom/visibility_shadow_dom.cy.ts +++ b/packages/driver/cypress/e2e/dom/visibility_shadow_dom.cy.ts @@ -5,13 +5,25 @@ const { $ } = Cypress describe('src/cypress/dom/visibility - shadow dom', () => { let add: (el: string, shadowEl: string, rootIdentifier: string) => JQuery - // #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 { @@ -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( `
@@ -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( `
underneath
@@ -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( `
@@ -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( `
@@ -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( `
diff --git a/packages/driver/src/dom/visibility/fastIsHidden.ts b/packages/driver/src/dom/visibility/fastIsHidden.ts index 2559794672..dc234a26b1 100644 --- a/packages/driver/src/dom/visibility/fastIsHidden.ts +++ b/packages/driver/src/dom/visibility/fastIsHidden.ts @@ -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') @@ -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, options: { checkOpacity: boolean } = { checkOpacity: true }): boolean { @@ -58,11 +70,12 @@ export function fastIsHidden (subject: JQuery | 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) { @@ -125,39 +138,61 @@ 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']) +// On the document root (``/``), only treat overflow as clipping when +// it is *explicitly* `hidden` or `clip`. `scroll` and `auto` here are usually +// the page's scroll container — and per the CSS spec, setting one axis to a +// non-`visible` value (e.g. `body { overflow-x: hidden }`) computes the other +// axis to `auto`. Treating that auto-converted value as clipping would block +// programmatic vertical scroll on every page that hides horizontal scrollbars. +const DOC_ROOT_CLIPPING_OVERFLOW = new Set(['hidden', 'clip']) + +// 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. The exception is ``/`` (see +// `DOC_ROOT_CLIPPING_OVERFLOW`). +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. And the subject must actually be *outside* the ancestor's - // bounds on that axis — many UI patterns use `overflow: hidden` for cosmetic clipping - // (border-radius, layout containment) without intent to hide in-bounds content. 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) { + const isDocRoot = current === doc.body || current === doc.documentElement + const allowed = isDocRoot ? DOC_ROOT_CLIPPING_OVERFLOW : CLIPPING_OVERFLOW const { overflowX, overflowY } = win.getComputedStyle(current) - const clipsX = overflowX === 'hidden' || overflowX === 'clip' - const clipsY = overflowY === 'hidden' || overflowY === 'clip' + const clipsX = offscreenX && allowed.has(overflowX) + const clipsY = offscreenY && allowed.has(overflowY) - if ((offscreenX && clipsX) || (offscreenY && clipsY)) { + if (clipsX || clipsY) { const ancestorRect = current.getBoundingClientRect() - if (offscreenX && clipsX && (rect.right <= ancestorRect.left || rect.left >= ancestorRect.right)) { + // Treat the subject as clipped only when it is *fully outside* the + // ancestor on the off-screen axis. Partial overlap means the subject + // has visible pixels inside the ancestor's clip region, so scrolling + // is still appropriate. + if (clipsX && (rect.right <= ancestorRect.left || rect.left >= ancestorRect.right)) { return true } - if (offscreenY && clipsY && (rect.bottom <= ancestorRect.top || rect.top >= ancestorRect.bottom)) { + if (clipsY && (rect.bottom <= ancestorRect.top || rect.top >= ancestorRect.bottom)) { return true } } - current = current.parentElement + current = getParentNode(current) } return false