From ceed5b622d659a752bbd4a96ecf08e540538a16c Mon Sep 17 00:00:00 2001 From: Shinobu Hayashi Date: Mon, 14 Jul 2025 22:36:36 +0900 Subject: [PATCH] feat(browser): support `toBeInViewport` utility method to assert element is in viewport or not (#8234) --- docs/guide/browser/assertion-api.md | 21 +++++ packages/browser/jest-dom.d.ts | 46 +++++++++++ .../browser/src/client/tester/expect/index.ts | 2 + .../client/tester/expect/toBeInViewport.ts | 81 +++++++++++++++++++ .../expect-dom/toBeInViewport.test.ts | 51 ++++++++++++ 5 files changed, 201 insertions(+) create mode 100644 packages/browser/src/client/tester/expect/toBeInViewport.ts create mode 100644 test/browser/fixtures/expect-dom/toBeInViewport.test.ts diff --git a/docs/guide/browser/assertion-api.md b/docs/guide/browser/assertion-api.md index 090d8b31f..7ce0e4f5f 100644 --- a/docs/guide/browser/assertion-api.md +++ b/docs/guide/browser/assertion-api.md @@ -300,6 +300,27 @@ await expect.element( ).toBeVisible() ``` +## toBeInViewport + +```ts +function toBeInViewport(options: { ratio?: number }): Promise +``` + +This allows you to check if an element is currently in viewport with [IntersectionObserver API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API). + +You can pass `ratio` argument as option, which means the minimal ratio of the element should be in viewport. `ratio` should be in 0~1. + +```ts +// A specific element is in viewport. +await expect.element(page.getByText('Welcome')).toBeInViewport() + +// 50% of a specific element should be in viewport +await expect.element(page.getByText('To')).toBeInViewport({ ratio: 0.5 }) + +// Full of a specific element should be in viewport +await expect.element(page.getByText('Vitest')).toBeInViewport({ ratio: 1 }) +``` + ## toContainElement ```ts diff --git a/packages/browser/jest-dom.d.ts b/packages/browser/jest-dom.d.ts index 21b5be83a..d3c4be89a 100644 --- a/packages/browser/jest-dom.d.ts +++ b/packages/browser/jest-dom.d.ts @@ -14,6 +14,52 @@ export interface TestingLibraryMatchers { * @see https://vitest.dev/guide/browser/assertion-api#tobeinthedocument */ toBeInTheDocument(): R + /** + * @description + * Assert whether an element is within the viewport or not. + * + * An element is considered to be in the viewport if any part of it intersects with the current viewport bounds. + * This matcher calculates the intersection ratio between the element and the viewport, similar to the + * IntersectionObserver API. + * + * The element must be in the document and have visible dimensions. Elements with display: none or + * visibility: hidden are considered not in viewport. + * @example + *
+ * Visible Element + *
+ * + *
+ * Hidden Element + *
+ * + *
+ * Large Element + *
+ * + * // Check if any part of element is in viewport + * await expect.element(page.getByTestId('visible-element')).toBeInViewport() + * + * // Check if element is outside viewport + * await expect.element(page.getByTestId('hidden-element')).not.toBeInViewport() + * + * // Check if at least 50% of element is visible + * await expect.element(page.getByTestId('large-element')).toBeInViewport({ ratio: 0.5 }) + * + * // Check if element is completely visible + * await expect.element(page.getByTestId('visible-element')).toBeInViewport({ ratio: 1 }) + * @see https://vitest.dev/guide/browser/assertion-api#tobeinviewport + */ + toBeInViewport(options?: { ratio?: number }): R /** * @description * This allows you to check if an element is currently visible to the user. diff --git a/packages/browser/src/client/tester/expect/index.ts b/packages/browser/src/client/tester/expect/index.ts index 69db1d533..6e59d2763 100644 --- a/packages/browser/src/client/tester/expect/index.ts +++ b/packages/browser/src/client/tester/expect/index.ts @@ -4,6 +4,7 @@ import toBeEmptyDOMElement from './toBeEmptyDOMElement' import { toBeDisabled, toBeEnabled } from './toBeEnabled' import toBeInTheDocument from './toBeInTheDocument' import { toBeInvalid, toBeValid } from './toBeInvalid' +import toBeInViewport from './toBeInViewport' import toBePartiallyChecked from './toBePartiallyChecked' import toBeRequired from './toBeRequired' import toBeVisible from './toBeVisible' @@ -28,6 +29,7 @@ export const matchers: MatchersObject = { toBeEnabled, toBeEmptyDOMElement, toBeInTheDocument, + toBeInViewport, toBeInvalid, toBeRequired, toBeValid, diff --git a/packages/browser/src/client/tester/expect/toBeInViewport.ts b/packages/browser/src/client/tester/expect/toBeInViewport.ts new file mode 100644 index 000000000..c256b089c --- /dev/null +++ b/packages/browser/src/client/tester/expect/toBeInViewport.ts @@ -0,0 +1,81 @@ +/** + * The MIT License (MIT) + * Copyright (c) 2017 Kent C. Dodds + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + */ + +import type { ExpectationResult, MatcherState } from '@vitest/expect' +import type { Locator } from '../locators' +import { getElementFromUserInput } from './utils' + +export default function toBeInViewport( + this: MatcherState, + actual: Element | Locator, + options?: { ratio?: number }, +): ExpectationResult { + const htmlElement = getElementFromUserInput(actual, toBeInViewport, this) + + const expectedRatio = options?.ratio ?? 0 + return getViewportIntersection(htmlElement, expectedRatio).then(({ pass, ratio }) => { + return { + pass, + message: () => { + const is = pass ? 'is' : 'is not' + const ratioText = expectedRatio > 0 ? ` with ratio ${expectedRatio}` : '' + const actualRatioText = ratio !== undefined ? ` (actual ratio: ${ratio.toFixed(3)})` : '' + return [ + this.utils.matcherHint( + `${this.isNot ? '.not' : ''}.toBeInViewport`, + 'element', + '', + ), + '', + `Received element ${is} in viewport${ratioText}${actualRatioText}:`, + ` ${this.utils.printReceived(htmlElement.cloneNode(false))}`, + ].join('\n') + }, + } + }) +} + +/** + * Get viewport intersection ratio using IntersectionObserver API + * This implementation follows Playwright's approach using IntersectionObserver as the primary mechanism + */ +async function getViewportIntersection(element: HTMLElement | SVGElement, expectedRatio: number): Promise<{ pass: boolean; ratio?: number }> { + // Use IntersectionObserver API to get the intersection ratio + // Following Playwright's exact pattern from viewportRatio function + const intersectionRatio = await new Promise((resolve) => { + // This mimics Playwright's Promise-based implementation in a synchronous context + const observer = new IntersectionObserver((entries) => { + if (entries.length > 0) { + resolve(entries[0].intersectionRatio) + } + else { + resolve(0) + } + observer.disconnect() + }) + + observer.observe(element) + + // Firefox workaround: requestAnimationFrame to ensure observer callback fires + // This is exactly how Playwright handles it + requestAnimationFrame(() => {}) + }) + + // Apply the same logic as Playwright: + // ratio > 0 && ratio > (expectedRatio - 1e-9) + const pass = intersectionRatio > 0 && intersectionRatio > (expectedRatio - 1e-9) + + return { pass, ratio: intersectionRatio } +} diff --git a/test/browser/fixtures/expect-dom/toBeInViewport.test.ts b/test/browser/fixtures/expect-dom/toBeInViewport.test.ts new file mode 100644 index 000000000..dd41ef5ad --- /dev/null +++ b/test/browser/fixtures/expect-dom/toBeInViewport.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { render } from './utils' + +describe('toBeInViewport', () => { + it('should work', async () => { + // Apply margin and width to small element, and padding to body and html because firefox's rendering causes a bit space between the element and the viewport + const { container } = render(` + +
+
foo
+ `) + + await expect(container.querySelector('#big')).toBeInViewport() + await expect(container.querySelector('#small')).not.toBeInViewport() + + // Scroll to make small element visible + container.querySelector('#small')?.scrollIntoView() + await expect(container.querySelector('#small')).toBeInViewport() + await expect(container.querySelector('#small')).toBeInViewport({ ratio: 1 }) + }) + + it('should respect ratio option', async () => { + const { container } = render(` + +
+ `) + + await expect(container.querySelector('div')).toBeInViewport() + await expect(container.querySelector('div')).toBeInViewport({ ratio: 0.1 }) + await expect(container.querySelector('div')).toBeInViewport({ ratio: 0.2 }) + await expect(container.querySelector('div')).toBeInViewport({ ratio: 0.24 }) + + // In this test, element's ratio is approximately 0.25 (viewport height / element height = 100vh / 400vh = 0.25) + // IntersectionObserver may return slightly different values due to browser rendering + await expect(container.querySelector('div')).toBeInViewport({ ratio: 0.24 }) + await expect(container.querySelector('div')).not.toBeInViewport({ ratio: 0.26 }) + + await expect(container.querySelector('div')).not.toBeInViewport({ ratio: 0.3 }) + await expect(container.querySelector('div')).not.toBeInViewport({ ratio: 0.7 }) + await expect(container.querySelector('div')).not.toBeInViewport({ ratio: 0.8 }) + }) + + it('should report intersection even if fully covered by other element', async () => { + const { container } = render(` +

hello

+
+ `) + + await expect(container.querySelector('h1')).toBeInViewport() + }) +}) \ No newline at end of file -- 2.51.2