diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index f7c5e7a2faf75..1ed17a916a9a0 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -1719,3 +1719,19 @@ Expected options currently selected. ### option: LocatorAssertions.toHaveValues.timeout = %%-csharp-java-python-assertions-timeout-%% * since: v1.23 + +## async method: LocatorAssertions.toIntersectViewport +* since: v1.30 +* langs: js + +Ensures the [Locator] points to an element that intersects viewport, according to the [intersection observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API). + +**Usage** + +```js +const locator = page.locator('button.submit'); +await expect(locator).toIntersectViewport(); +``` + +### option: LocatorAssertions.toIntersectViewport.timeout = %%-js-assertions-timeout-%% +* since: v1.30 diff --git a/docs/src/test-assertions-js.md b/docs/src/test-assertions-js.md index 318ce106df623..2c5ce5a51cfe2 100644 --- a/docs/src/test-assertions-js.md +++ b/docs/src/test-assertions-js.md @@ -43,6 +43,7 @@ By default, the timeout for assertions is set to 5 seconds. Learn more about [va | [`method: LocatorAssertions.toHaveText`] | Element matches text | | [`method: LocatorAssertions.toHaveValue`] | Input has a value | | [`method: LocatorAssertions.toHaveValues`] | Select has options selected | +| [`method: LocatorAssertions.toIntersectViewport`] | Element intersects viewport | | [`method: PageAssertions.toHaveScreenshot#1`] | Page has a screenshot | | [`method: PageAssertions.toHaveTitle`] | Page has a title | | [`method: PageAssertions.toHaveURL`] | Page has a URL | diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index 9137b5f5f0393..27e7cab02cdac 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1329,16 +1329,7 @@ export class Frame extends SdkObject { const element = injected.querySelector(info.parsed, document, info.strict); if (!element) return 0; - return await new Promise(resolve => { - const observer = new IntersectionObserver(entries => { - resolve(entries[0].intersectionRatio); - observer.disconnect(); - }); - observer.observe(element); - // Firefox doesn't call IntersectionObserver callback unless - // there are rafs. - requestAnimationFrame(() => {}); - }); + return await injected.viewportRatio(element); }, { info: resolved.info }); }, this._page._timeoutSettings.timeout({})); } @@ -1451,7 +1442,7 @@ export class Frame extends SdkObject { const injected = await context.injectedScript(); progress.throwIfAborted(); - const { log, matches, received } = await injected.evaluate((injected, { info, options, snapshotName }) => { + const { log, matches, received } = await injected.evaluate(async (injected, { info, options, snapshotName }) => { const elements = info ? injected.querySelectorAll(info.parsed, document) : []; const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array'); let log = ''; @@ -1463,7 +1454,7 @@ export class Frame extends SdkObject { log = ` locator resolved to ${injected.previewNode(elements[0])}`; if (snapshotName) injected.markTargetElements(new Set(elements), snapshotName); - return { log, ...injected.expect(elements[0], options, elements) }; + return { log, ...(await injected.expect(elements[0], options, elements)) }; }, { info, options, snapshotName: progress.metadata.afterSnapshot }); if (log) diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index a1b46c8bc1214..00cb2fb01c88e 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -383,6 +383,19 @@ export class InjectedScript { return isElementVisible(element); } + async viewportRatio(element: Element): Promise { + return await new Promise(resolve => { + const observer = new IntersectionObserver(entries => { + resolve(entries[0].intersectionRatio); + observer.disconnect(); + }); + observer.observe(element); + // Firefox doesn't call IntersectionObserver callback unless + // there are rafs. + requestAnimationFrame(() => {}); + }); + } + pollRaf(predicate: Predicate): InjectedScriptPoll { return this.poll(predicate, next => requestAnimationFrame(next)); } @@ -1106,7 +1119,7 @@ export class InjectedScript { this.onGlobalListenersRemoved.add(addHitTargetInterceptorListeners); } - expect(element: Element | undefined, options: FrameExpectParams, elements: Element[]) { + async expect(element: Element | undefined, options: FrameExpectParams, elements: Element[]) { const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array'); if (isArray) return this.expectArray(elements, options); @@ -1117,13 +1130,16 @@ export class InjectedScript { // expect(locator).not.toBeVisible() passes when there is no element. if (options.isNot && options.expression === 'to.be.visible') return { matches: false }; + // expect(locator).not.toIntersectViewport() passes when there is no element. + if (options.isNot && options.expression === 'to.intersect.viewport') + return { matches: false }; // When none of the above applies, expect does not match. return { matches: options.isNot }; } - return this.expectSingleElement(element, options); + return await this.expectSingleElement(element, options); } - private expectSingleElement(element: Element, options: FrameExpectParams): { matches: boolean, received?: any } { + private async expectSingleElement(element: Element, options: FrameExpectParams): Promise<{ matches: boolean, received?: any }> { const expression = options.expression; { @@ -1171,6 +1187,13 @@ export class InjectedScript { return { received, matches }; } } + { + // Viewport intersection + if (expression === 'to.intersect.viewport') { + const ratio = await this.viewportRatio(element); + return { received: `viewport ratio ${ratio}`, matches: ratio > 0 }; + } + } // Multi-Select/Combobox { diff --git a/packages/playwright-test/src/expect.ts b/packages/playwright-test/src/expect.ts index 048310d47ffce..2a553d65ea7a2 100644 --- a/packages/playwright-test/src/expect.ts +++ b/packages/playwright-test/src/expect.ts @@ -38,6 +38,7 @@ import { toHaveURL, toHaveValue, toHaveValues, + toIntersectViewport, toPass } from './matchers/matchers'; import { toMatchSnapshot, toHaveScreenshot } from './matchers/toMatchSnapshot'; @@ -143,6 +144,7 @@ const customMatchers = { toHaveURL, toHaveValue, toHaveValues, + toIntersectViewport, toMatchSnapshot, toHaveScreenshot, toPass, diff --git a/packages/playwright-test/src/matchers/matchers.ts b/packages/playwright-test/src/matchers/matchers.ts index d18cbc0fe0fb1..b6884cc2c680e 100644 --- a/packages/playwright-test/src/matchers/matchers.ts +++ b/packages/playwright-test/src/matchers/matchers.ts @@ -119,6 +119,16 @@ export function toBeVisible( }, options); } +export function toIntersectViewport( + this: ReturnType, + locator: LocatorEx, + options?: { timeout?: number }, +) { + return toBeTruthy.call(this, 'toIntersectViewport', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return await locator._expect(customStackTrace, 'to.intersect.viewport', { isNot, timeout }); + }, options); +} + export function toContainText( this: ReturnType, locator: LocatorEx, @@ -342,4 +352,4 @@ export async function toPass( return { message, pass: this.isNot }; } return { pass: !this.isNot, message: () => '' }; -} \ No newline at end of file +} diff --git a/packages/playwright-test/types/test.d.ts b/packages/playwright-test/types/test.d.ts index 4b7962ca874f5..ff8dec2c46294 100644 --- a/packages/playwright-test/types/test.d.ts +++ b/packages/playwright-test/types/test.d.ts @@ -4641,6 +4641,26 @@ interface LocatorAssertions { */ timeout?: number; }): Promise; + + /** + * Ensures the [Locator] points to an element that intersects viewport, according to the + * [intersection observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API). + * + * **Usage** + * + * ```js + * const locator = page.locator('button.submit'); + * await expect(locator).toIntersectViewport(); + * ``` + * + * @param options + */ + toIntersectViewport(options?: { + /** + * Time to retry the assertion for. Defaults to `timeout` in `TestConfig.expect`. + */ + timeout?: number; + }): Promise; } /** diff --git a/tests/page/expect-misc.spec.ts b/tests/page/expect-misc.spec.ts index 37d3916d450d5..a8e1625fbcee4 100644 --- a/tests/page/expect-misc.spec.ts +++ b/tests/page/expect-misc.spec.ts @@ -285,3 +285,38 @@ test.describe('toHaveId', () => { await expect(locator).toHaveId('node'); }); }); + +test.describe('toIntersectViewport', () => { + test('should work', async ({ page }) => { + await page.setContent(` +
+ foo + `); + await expect(page.locator('#big')).toIntersectViewport(); + await expect(page.locator('#small')).not.toIntersectViewport(); + await page.locator('#small').scrollIntoViewIfNeeded(); + await expect(page.locator('#small')).toIntersectViewport(); + }); + + test('should have good stack', async ({ page }) => { + let error; + try { + await expect(page.locator('body')).not.toIntersectViewport({ timeout: 100 }); + } catch (e) { + error = e; + } + expect(error).toBeTruthy(); + expect(/unexpected value "viewport ratio \d+/.test(error.stack)).toBe(true); + const stackFrames = error.stack.split('\n').filter(line => line.trim().startsWith('at ')); + expect(stackFrames.length).toBe(1); + expect(stackFrames[0]).toContain(__filename); + }); + + test('should report intersection even if fully covered by other element', async ({ page }) => { + await page.setContent(` +

hello

+