diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 0d1a5fa11c8..e9c795c9846 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -15,6 +15,7 @@ - Fixed an issue in Angular component testing where multiple projects in a monorepo sharing the same directory basename (e.g. `libs/feature-a/feat-shell` and `libs/feature-b/feat-shell`) would intermittently fail with spec-resolution errors when run in parallel. The temporary `tsconfig.json` generated by `@cypress/webpack-dev-server` was keyed only on the directory basename, so parallel runs would race on the same file. The temp directory is now suffixed with a short hash of the full project root path, giving each project its own isolated config. Fixes [#33634](https://github.com/cypress-io/cypress/issues/33634). - Fixed a race during `cypress open` config reload where rapid changes to `cypress.config.js` could leave the specs list stale. Two overlapping config-reload calls would tear down each other's IPC child processes (surfacing as `ERR_STREAM_DESTROYED`), causing both reloads to fail and the specs list to never refresh. Fixed in [#33775](https://github.com/cypress-io/cypress/pull/33775). - Fixed an issue where a transient Firefox launch failure caused Cypress to exit instead of retrying the browser launch. Fixed in [#33770](https://github.com/cypress-io/cypress/pull/33770). +- Fixed an issue where, with `experimentalFastVisibility` enabled, elements scrolled below the fold were incorrectly reported as hidden. The fast visibility algorithm now scrolls off-screen subjects into view using the `scrollBehavior` config before sampling visibility, matching the behavior of action commands like `cy.click()`. Fixes [#33045](https://github.com/cypress-io/cypress/issues/33045). **Dependency Updates:** diff --git a/packages/driver/cypress/e2e/dom/visibility.cy.ts b/packages/driver/cypress/e2e/dom/visibility.cy.ts index cd91d993762..28813c316f8 100644 --- a/packages/driver/cypress/e2e/dom/visibility.cy.ts +++ b/packages/driver/cypress/e2e/dom/visibility.cy.ts @@ -329,6 +329,7 @@ describe('src/cypress/dom/visibility', { 'overflow-flex-container', 'overflow-complex-scenarios', Cypress.browser.name !== 'firefox' || mode === 'legacy' ? 'clip-path-scenarios' : undefined, + 'scrollable-viewport-scenarios', ]) }) @@ -366,6 +367,104 @@ describe('src/cypress/dom/visibility', { ]) }) }) + + it('scrolls off-screen elements into view before checking visibility', () => { + cy.visit('/fixtures/visibility/overflow.html') + cy.window().then((win) => { + const doc = win.document + const el = doc.createElement('div') + + el.textContent = 'off-screen element' + el.style.cssText = 'position: absolute; top: 5000px; width: 100px; height: 100px; background: green;' + doc.body.appendChild(el) + + const rect = el.getBoundingClientRect() + + expect(rect.top, `element top (${rect.top}) should be greater than viewport height (${win.innerHeight})`).to.be.greaterThan(win.innerHeight) + expect(Cypress.config('experimentalFastVisibility'), `experimentalFastVisibility should be ${mode === 'fast'}`).to.eq(mode === 'fast') + + if (mode === 'fast') { + expect(dom.isVisible(el), 'off-screen element should be visible in fast mode (scrolled into view)').to.be.true + } else { + expect(dom.isVisible(el), 'off-screen element should be visible in legacy mode').to.be.true + } + }) + }) + + it('does not scroll when scrollBehavior is false', { + scrollBehavior: false, + }, () => { + cy.visit('/fixtures/visibility/overflow.html') + cy.window().then((win) => { + const doc = win.document + const el = doc.createElement('div') + + el.textContent = 'off-screen element' + el.style.cssText = 'position: absolute; top: 5000px; width: 100px; height: 100px; background: green;' + doc.body.appendChild(el) + + const rect = el.getBoundingClientRect() + + expect(rect.top, `element top (${rect.top}) should be greater than viewport height (${win.innerHeight})`).to.be.greaterThan(win.innerHeight) + + if (mode === 'fast') { + expect(dom.isVisible(el), 'off-screen element should be hidden in fast mode when scrollBehavior is false').to.be.false + } else { + expect(dom.isVisible(el), 'off-screen element should be visible in legacy mode').to.be.true + } + }) + }) + + // body { overflow-x: hidden } is a common pattern; per CSS spec, the orthogonal + // axis still scrolls. The clipping-ancestor guard must not block vertical scroll + // just because an ancestor clips horizontally. + it('still scrolls vertically when an ancestor only clips the horizontal axis', () => { + cy.visit('/fixtures/visibility/overflow.html') + cy.window().then((win) => { + const doc = win.document + + doc.body.style.overflowX = 'hidden' + + const el = doc.createElement('div') + + el.textContent = 'below fold with body overflow-x hidden' + el.style.cssText = 'position: absolute; top: 5000px; width: 100px; height: 100px; background: green;' + doc.body.appendChild(el) + + if (mode === 'fast') { + expect(dom.isVisible(el), 'should still scroll vertically when only the orthogonal axis is clipped').to.be.true + } else { + expect(dom.isVisible(el)).to.be.true + } + }) + }) + + // Many UI patterns wrap content in `overflow: hidden` for cosmetic clipping + // (border-radius, layout containment) without intent to hide child content. + // The clipping-ancestor guard must only short-circuit when the subject is + // actually outside the ancestor's bounds on the off-screen axis. + it('still scrolls a below-the-fold element fully contained in an overflow: hidden card', () => { + cy.visit('/fixtures/visibility/overflow.html') + cy.window().then((win) => { + const doc = win.document + const card = doc.createElement('div') + + card.style.cssText = 'position: absolute; top: 5000px; left: 50px; width: 200px; height: 200px; overflow: hidden; border-radius: 12px;' + doc.body.appendChild(card) + + const el = doc.createElement('div') + + el.textContent = 'in-bounds child of overflow:hidden card' + el.style.cssText = 'width: 100px; height: 50px; margin: 20px; background: green;' + card.appendChild(el) + + if (mode === 'fast') { + expect(dom.isVisible(el), 'in-bounds child of overflow:hidden card should be visible when scrolled into view').to.be.true + } else { + expect(dom.isVisible(el)).to.be.true + } + }) + }) }) } diff --git a/packages/driver/cypress/fixtures/visibility/overflow.html b/packages/driver/cypress/fixtures/visibility/overflow.html index ea799dd54d5..8fd2122bd73 100644 --- a/packages/driver/cypress/fixtures/visibility/overflow.html +++ b/packages/driver/cypress/fixtures/visibility/overflow.html @@ -274,6 +274,13 @@

Clip-Path Scenarios (note: legacy mode does not support clip-path)

masked by CSS mask
+
+

Scrollable Viewport Scenarios

+
+ Below the fold element +
+
+

Viewport Scenarios

Element outside viewport
diff --git a/packages/driver/src/cy/actionability.ts b/packages/driver/src/cy/actionability.ts index 009ce6cb9d6..91a342e195b 100644 --- a/packages/driver/src/cy/actionability.ts +++ b/packages/driver/src/cy/actionability.ts @@ -9,6 +9,7 @@ import type { ElWindowPosition, ElViewportPosition, ElementPositioning } from '. import $elements from '../dom/elements' import $errUtils from '../cypress/error_utils' import { callNativeMethod, getNativeProp } from '../dom/elements/nativeProps' +import { scrollBehaviorOptionsMap } from '../util/scrollBehavior' const debug = debugFn('cypress:driver:actionability') const delay = 50 @@ -27,13 +28,6 @@ const dispatchPrimedChangeEvents = function (state) { } } -const scrollBehaviorOptionsMap = { - top: 'start', - bottom: 'end', - center: 'center', - nearest: 'nearest', -} - const getPositionFromArguments = function (positionOrX, y, options) { let position; let x diff --git a/packages/driver/src/dom/visibility/MIGRATION_GUIDE.md b/packages/driver/src/dom/visibility/MIGRATION_GUIDE.md index d7da670e118..ce510e7a3ca 100644 --- a/packages/driver/src/dom/visibility/MIGRATION_GUIDE.md +++ b/packages/driver/src/dom/visibility/MIGRATION_GUIDE.md @@ -29,12 +29,10 @@ Enable fast visibility if you experience: - Your tests rely heavily on Shadow DOM elements - You have comprehensive Shadow DOM test coverage - Your application uses Shadow DOM extensively -- You rely extensively on asserting the visibility of elements that are outside the browser's viewport - You rely on asserting the visibility state of elements that have `pointer-events:none` **Current Limitations**: - The fast visibility algorithm does not yet fully support Shadow DOM elements. Tests that interact with Shadow DOM elements may fail or behave incorrectly. -- The fast visibility algorithm considers any element that is outside of the browser's viewport as hidden. While this is an incompatibility with the legacy visibility approach, it is aligned with the visibility behavior of elements that are scrolled out of view within a scrollable container. ## Algorithm Differences @@ -54,7 +52,7 @@ While comprehensive, this list may not be complete. Additional discrepancies may | **[overflow-scroll-scenarios](../../../cypress/fixtures/visibility/overflow.html)** | Parent with clip-path polygon that clips everything | ✅ Yes | ❌ No | ❌ No | `clip-path: polygon(0 0, 0 0, 0 0, 0 0)` | | **[overflow-scroll-scenarios](../../../cypress/fixtures/visibility/overflow.html)** | Element outside clip-path polygon | ✅ Yes | ❌ No | ❌ No | Child element of polygon clip-path parent | | **[overflow-scroll-scenarios](../../../cypress/fixtures/visibility/overflow.html)** | Element outside clip-path inset | ✅ Yes | ❌ No | ❌ No | Child element of `clip-path: inset(25% 25% 25% 25%)` | -| **[viewport-scenarios](../../../cypress/fixtures/visibility/overflow.html)** | Absolutely positioned element outside of the viewport | ✅ Yes | ❌ No | ❌ No | Elements that are outside of the viewport must be scrolled to before the fast algorithm will consider them visible. This is aligned with scroll-container visibility. | +| **[viewport-scenarios](../../../cypress/fixtures/visibility/overflow.html)** | Absolutely positioned element outside of the viewport | ✅ Yes | ❌ No | ❌ No | Elements positioned outside the scrollable document bounds (e.g. negative absolute positioning) cannot be scrolled into view and remain hidden. Elements that are merely below the fold are automatically scrolled into view per the `scrollBehavior` config before visibility is checked. | | **[z-index-coverage](../../../cypress/fixtures/visibility/positioning.html)** | Covered by higher z-index element | ✅ Yes | ❌ No | ❌ No | Element covered by another element with higher z-index | | **[clip-scenarios](../../../cypress/fixtures/visibility/overflow.html)** | Element clipped by CSS clip property | ✅ Yes | ❌ No | ❌ No | Element with `clip: rect(0, 0, 0, 0)` or similar clipping | | **[transform](../../../cypress/fixtures/visibility/transforms.html)** | Element transformed outside viewport | ✅ Yes | ❌ No | ❌ No | Element with `transform: translateX(-9999px)` or similar | @@ -129,9 +127,11 @@ cy.get('.rotated-element').should('be.hidden') ### Issue 2: Elements Outside Viewport -**Problem**: Elements positioned outside the viewport are now correctly identified as hidden. +**Problem**: Elements positioned outside the scrollable document bounds (e.g. `position: absolute; top: -100px`) are identified as hidden because they cannot be scrolled into view. -**Solution**: Scroll the element into view before testing: +**Note**: Elements that are simply below the fold or off-screen but within the scrollable document are now automatically scrolled into view before visibility is checked, using the `scrollBehavior` configuration setting. This matches the behavior of action commands like `cy.click()`. If `scrollBehavior` is set to `false`, off-screen elements will still be considered hidden. + +**Solution** (for elements outside scrollable bounds): ```javascript // Before @@ -290,7 +290,7 @@ cy.get('.modal').should('have.css', 'display', 'block') ## Troubleshooting **If the element should be visible, but Cypress determines that it is hidden:** -- Verify that the element is actually visible, and within the browser viewport. If you have to scroll to view the element, Cypress will not consider it visible. +- Verify that the element is actually visible. Elements below the fold are automatically scrolled into view before checking visibility (per the `scrollBehavior` config). Elements outside the scrollable document bounds (e.g. negative absolute positioning) cannot be scrolled into view and will be considered hidden. - Verify that the element has proper dimensions. If either its height or width are zero, re-assess if this is the best element to be interacting with. - Verify that the element does not have `pointer-events:none` - In some extreme CSS `transform` scenarios, the element can be so distorted that Cypress fails to sample a visible point. If you hit this edge case, re-assess the usefulness of the assertion and/or interaction. diff --git a/packages/driver/src/dom/visibility/fastIsHidden.ts b/packages/driver/src/dom/visibility/fastIsHidden.ts index e1b73eb420f..25597946724 100644 --- a/packages/driver/src/dom/visibility/fastIsHidden.ts +++ b/packages/driver/src/dom/visibility/fastIsHidden.ts @@ -1,6 +1,7 @@ import $elements from '../elements' import { memoize } from './memoize' import { unwrap, wrap, isJquery } from '../jquery' +import { scrollBehaviorOptionsMap } from '../../util/scrollBehavior' import Debug from 'debug' const debug = Debug('cypress:driver:dom:visibility:fastIsHidden') @@ -55,7 +56,22 @@ export function fastIsHidden (subject: JQuery | HTMLElement, option return true } - const boundingRect = getBoundingClientRect(subject) + 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)) { + const scrollBehavior = Cypress.config('scrollBehavior') + + if (scrollBehavior !== false) { + const block = scrollBehaviorOptionsMap[scrollBehavior as string] || 'start' + + subject.scrollIntoView({ block, behavior: 'instant' as ScrollBehavior }) + boundingRect = subject.getBoundingClientRect() + } + } if (visibleToUser(subject, boundingRect)) { debug('visibleToUser', subject, boundingRect) @@ -96,6 +112,57 @@ function visibleToUser (el: HTMLElement, rect: DOMRect, maxDepth: number = 2, cu }) } +function isOutsideViewport (el: HTMLElement, rect: DOMRect): boolean { + const win = el.ownerDocument.defaultView + + if (!win) return false + + return ( + rect.bottom <= 0 || + rect.right <= 0 || + rect.top >= win.innerHeight || + rect.left >= win.innerWidth + ) +} + +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. 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 + + while (current) { + const { overflowX, overflowY } = win.getComputedStyle(current) + const clipsX = overflowX === 'hidden' || overflowX === 'clip' + const clipsY = overflowY === 'hidden' || overflowY === 'clip' + + if ((offscreenX && clipsX) || (offscreenY && clipsY)) { + const ancestorRect = current.getBoundingClientRect() + + if (offscreenX && clipsX && (rect.right <= ancestorRect.left || rect.left >= ancestorRect.right)) { + return true + } + + if (offscreenY && clipsY && (rect.bottom <= ancestorRect.top || rect.top >= ancestorRect.bottom)) { + return true + } + } + + current = current.parentElement + } + + return false +} + function subDivideRect ({ x, y, width, height }: DOMRect): DOMRect[] { return [ DOMRect.fromRect({ diff --git a/packages/driver/src/util/scrollBehavior.ts b/packages/driver/src/util/scrollBehavior.ts new file mode 100644 index 00000000000..e650ed95df6 --- /dev/null +++ b/packages/driver/src/util/scrollBehavior.ts @@ -0,0 +1,8 @@ +// Maps Cypress's `scrollBehavior` config values to the corresponding +// `ScrollLogicalPosition` accepted by `Element.scrollIntoView`. +export const scrollBehaviorOptionsMap: Record = { + top: 'start', + bottom: 'end', + center: 'center', + nearest: 'nearest', +}