From 68e170ef898e7f973dded1dde68114115ed82d98 Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Fri, 10 Feb 2023 04:33:22 -0800 Subject: [PATCH] feat: implement expect(locator).toBeInViewport() (#20668) The method accepts a `ratio` option to assert the ratio of the element in viewport. `ratio` defaults to `Number.MIN_VALUE`. NOTE: this reverts commit d950f5b6ee3fee4b825831983d5af5b197bda769 and adds `ratio` option support + does the rename. Fixes #8740 --- docs/src/api/class-locatorassertions.md | 78 +++++++++++++++++++ docs/src/test-assertions-js.md | 1 + .../playwright-core/src/protocol/validator.ts | 1 + packages/playwright-core/src/server/frames.ts | 4 +- .../src/server/injected/injectedScript.ts | 16 +++- .../playwright-test/src/matchers/expect.ts | 2 + .../playwright-test/src/matchers/matchers.ts | 10 +++ packages/playwright-test/types/test.d.ts | 31 ++++++++ packages/protocol/src/channels.ts | 2 + packages/protocol/src/protocol.yml | 1 + tests/page/expect-misc.spec.ts | 53 +++++++++++++ 11 files changed, 194 insertions(+), 5 deletions(-) diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index 85f0fcc54c..c8fb6180aa 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -679,6 +679,83 @@ await Expect(locator).ToBeHiddenAsync(); ### option: LocatorAssertions.toBeHidden.timeout = %%-csharp-java-python-assertions-timeout-%% * since: v1.18 +## async method: LocatorAssertions.toBeInViewport +* since: v1.31 +* langs: + - alias-java: isInViewport + +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'); +// Make sure at least some part of element intersects viewport. +await expect(locator).toBeInViewport(); +// Make sure element is fully outside of viewport. +await expect(locator).not.toBeInViewport(); +// Make sure strictly more than half of the element intersects viewport. +await expect(locator).toBeInViewport({ ratio: 0.5 }); +``` + +```java +Locator locator = page.locator("button.submit"); +// Make sure at least some part of element intersects viewport. +assertThat(locator).isInViewport(); +// Make sure element is fully outside of viewport. +assertThat(locator).not().isInViewport(); +// Make sure strictly more than half of the element intersects viewport. +assertThat(locator).isInViewport(new LocatorAssertions.IsInViewportOptions().setRatio(0.5)); +``` + +```csharp +var locator = Page.Locator("button.submit"); +// Make sure at least some part of element intersects viewport. +await Expect(locator).ToBeInViewportAsync(); +// Make sure element is fully outside of viewport. +await Expect(locator).Not.ToBeInViewportAsync(); +// Make sure strictly more than half of the element intersects viewport. +await Expect(locator).ToBeInViewportAsync(new() { Ratio = 0.5 }); +``` + +```python async +from playwright.async_api import expect + +locator = page.locator("button.submit") +# Make sure at least some part of element intersects viewport. +await expect(locator).to_be_in_viewport() +# Make sure element is fully outside of viewport. +await expect(locator).not_to_be_in_viewport() +# Make sure strictly more than half of the element intersects viewport. +await expect(locator).to_be_in_viewport(ratio=0.5); +``` + +```python sync +from playwright.sync_api import expect + +locator = page.locator("button.submit") +# Make sure at least some part of element intersects viewport. +expect(locator).to_be_in_viewport() +# Make sure element is fully outside of viewport. +expect(locator).not_to_be_in_viewport() +# Make sure strictly more than half of the element intersects viewport. +expect(locator).to_be_in_viewport(ratio=0.5); +``` + + +### option: LocatorAssertions.toBeInViewport.ratio +* since: v1.31 +- `ratio` <[float]> + +The minimal ratio of the element to intersect viewport. Element's ratio should be strictly greater than +this number. Defaults to `0`. + +### option: LocatorAssertions.toBeInViewport.timeout = %%-js-assertions-timeout-%% +* since: v1.31 + +### option: LocatorAssertions.toBeInViewport.timeout = %%-csharp-java-python-assertions-timeout-%% +* since: v1.31 + ## async method: LocatorAssertions.toBeVisible * since: v1.20 * langs: @@ -1671,3 +1748,4 @@ Expected options currently selected. ### option: LocatorAssertions.toHaveValues.timeout = %%-csharp-java-python-assertions-timeout-%% * since: v1.23 + diff --git a/docs/src/test-assertions-js.md b/docs/src/test-assertions-js.md index 4d6a867f27..389b9195e8 100644 --- a/docs/src/test-assertions-js.md +++ b/docs/src/test-assertions-js.md @@ -31,6 +31,7 @@ By default, the timeout for assertions is set to 5 seconds. Learn more about [va | [`method: LocatorAssertions.toBeEnabled`] | Element is enabled | | [`method: LocatorAssertions.toBeFocused`] | Element is focused | | [`method: LocatorAssertions.toBeHidden`] | Element is not visible | +| [`method: LocatorAssertions.toBeInViewport`] | Element intersects viewport | | [`method: LocatorAssertions.toBeVisible`] | Element is visible | | [`method: LocatorAssertions.toContainText`] | Element contains text | | [`method: LocatorAssertions.toHaveAttribute`] | Element has a DOM attribute | diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 85f349c5fe..0ccc146698 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1586,6 +1586,7 @@ scheme.FrameExpectParams = tObject({ expectedText: tOptional(tArray(tType('ExpectedTextValue'))), expectedNumber: tOptional(tNumber), expectedValue: tOptional(tType('SerializedArgument')), + viewportRatio: tOptional(tNumber), useInnerText: tOptional(tBoolean), isNot: tBoolean, timeout: tOptional(tNumber), diff --git a/packages/playwright-core/src/server/frames.ts b/packages/playwright-core/src/server/frames.ts index ea85e669ef..22f424d7f2 100644 --- a/packages/playwright-core/src/server/frames.ts +++ b/packages/playwright-core/src/server/frames.ts @@ -1422,7 +1422,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 = ''; @@ -1434,7 +1434,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 7fa2344dc5..2ce835e5d1 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -1119,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); @@ -1130,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.toBeInViewport() passes when there is no element. + if (options.isNot && options.expression === 'to.be.in.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; { @@ -1184,6 +1187,13 @@ export class InjectedScript { return { received, matches }; } } + { + // Viewport intersection + if (expression === 'to.be.in.viewport') { + const ratio = await this.viewportRatio(element); + return { received: `viewport ratio ${ratio}`, matches: ratio > (options.viewportRatio ?? 0) }; + } + } // Multi-Select/Combobox { diff --git a/packages/playwright-test/src/matchers/expect.ts b/packages/playwright-test/src/matchers/expect.ts index 9313e1bca2..12e29b2c1a 100644 --- a/packages/playwright-test/src/matchers/expect.ts +++ b/packages/playwright-test/src/matchers/expect.ts @@ -24,6 +24,7 @@ import { toBeEnabled, toBeFocused, toBeHidden, + toBeInViewport, toBeOK, toBeVisible, toContainText, @@ -129,6 +130,7 @@ const customMatchers = { toBeEnabled, toBeFocused, toBeHidden, + toBeInViewport, toBeOK, toBeVisible, toContainText, diff --git a/packages/playwright-test/src/matchers/matchers.ts b/packages/playwright-test/src/matchers/matchers.ts index e504115330..9337b12d44 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 toBeInViewport( + this: ReturnType, + locator: LocatorEx, + options?: { timeout?: number, ratio?: number }, +) { + return toBeTruthy.call(this, 'toBeInViewport', locator, 'Locator', async (isNot, timeout, customStackTrace) => { + return await locator._expect(customStackTrace, 'to.be.in.viewport', { isNot, viewportRatio: options?.ratio, timeout }); + }, options); +} + export function toContainText( this: ReturnType, locator: LocatorEx, diff --git a/packages/playwright-test/types/test.d.ts b/packages/playwright-test/types/test.d.ts index 56d50fe627..f38161479a 100644 --- a/packages/playwright-test/types/test.d.ts +++ b/packages/playwright-test/types/test.d.ts @@ -4480,6 +4480,37 @@ 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'); + * // Make sure at least some part of element intersects viewport. + * await expect(locator).toBeInViewport(); + * // Make sure element is fully outside of viewport. + * await expect(locator).not.toBeInViewport(); + * // Make sure strictly more than half of the element intersects viewport. + * await expect(locator).toBeInViewport({ ratio: 0.5 }); + * ``` + * + * @param options + */ + toBeInViewport(options?: { + /** + * The minimal ratio of the element to intersect viewport. Element's ratio should be strictly greater than this + * number. Defaults to `0`. + */ + ratio?: number; + + /** + * Time to retry the assertion for. Defaults to `timeout` in `TestConfig.expect`. + */ + timeout?: number; + }): Promise; + /** * Ensures that [Locator] points to an [attached](https://playwright.dev/docs/actionability#attached) and * [visible](https://playwright.dev/docs/actionability#visible) DOM node. diff --git a/packages/protocol/src/channels.ts b/packages/protocol/src/channels.ts index 3e75809e84..cb6f9e8ce9 100644 --- a/packages/protocol/src/channels.ts +++ b/packages/protocol/src/channels.ts @@ -2844,6 +2844,7 @@ export type FrameExpectParams = { expectedText?: ExpectedTextValue[], expectedNumber?: number, expectedValue?: SerializedArgument, + viewportRatio?: number, useInnerText?: boolean, isNot: boolean, timeout?: number, @@ -2853,6 +2854,7 @@ export type FrameExpectOptions = { expectedText?: ExpectedTextValue[], expectedNumber?: number, expectedValue?: SerializedArgument, + viewportRatio?: number, useInnerText?: boolean, timeout?: number, }; diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 0551af5876..34d2bc1f1b 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -2144,6 +2144,7 @@ Frame: items: ExpectedTextValue expectedNumber: number? expectedValue: SerializedArgument? + viewportRatio: number? useInnerText: boolean? isNot: boolean timeout: number? diff --git a/tests/page/expect-misc.spec.ts b/tests/page/expect-misc.spec.ts index 37d3916d45..50c8e75d29 100644 --- a/tests/page/expect-misc.spec.ts +++ b/tests/page/expect-misc.spec.ts @@ -285,3 +285,56 @@ test.describe('toHaveId', () => { await expect(locator).toHaveId('node'); }); }); + +test.describe('toBeInViewport', () => { + test('should work', async ({ page }) => { + await page.setContent(` +
+
foo
+ `); + await expect(page.locator('#big')).toBeInViewport(); + await expect(page.locator('#small')).not.toBeInViewport(); + await page.locator('#small').scrollIntoViewIfNeeded(); + await expect(page.locator('#small')).toBeInViewport(); + }); + + test('should respect ratio option', async ({ page }) => { + await page.setContent(` + +
+ `); + await expect(page.locator('div')).toBeInViewport(); + await expect(page.locator('div')).toBeInViewport({ ratio: 0.1 }); + await expect(page.locator('div')).toBeInViewport({ ratio: 0.2 }); + + // In this test, element's ratio is 0.25. Make sure `ratio` is compared strictly. + await expect(page.locator('div')).toBeInViewport({ ratio: 0.24 }); + await expect(page.locator('div')).not.toBeInViewport({ ratio: 0.25 }); + + await expect(page.locator('div')).not.toBeInViewport({ ratio: 0.3 }); + await expect(page.locator('div')).not.toBeInViewport({ ratio: 0.7 }); + await expect(page.locator('div')).not.toBeInViewport({ ratio: 0.8 }); + }); + + test('should have good stack', async ({ page }) => { + let error; + try { + await expect(page.locator('body')).not.toBeInViewport({ 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

+