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 d950f5b6ee and
adds `ratio` option support + does the rename.
Fixes #8740
This commit is contained in:
parent
778dd20403
commit
68e170ef89
|
|
@ -679,6 +679,83 @@ await Expect(locator).ToBeHiddenAsync();
|
||||||
### option: LocatorAssertions.toBeHidden.timeout = %%-csharp-java-python-assertions-timeout-%%
|
### option: LocatorAssertions.toBeHidden.timeout = %%-csharp-java-python-assertions-timeout-%%
|
||||||
* since: v1.18
|
* 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
|
## async method: LocatorAssertions.toBeVisible
|
||||||
* since: v1.20
|
* since: v1.20
|
||||||
* langs:
|
* langs:
|
||||||
|
|
@ -1671,3 +1748,4 @@ Expected options currently selected.
|
||||||
|
|
||||||
### option: LocatorAssertions.toHaveValues.timeout = %%-csharp-java-python-assertions-timeout-%%
|
### option: LocatorAssertions.toHaveValues.timeout = %%-csharp-java-python-assertions-timeout-%%
|
||||||
* since: v1.23
|
* since: v1.23
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.toBeEnabled`] | Element is enabled |
|
||||||
| [`method: LocatorAssertions.toBeFocused`] | Element is focused |
|
| [`method: LocatorAssertions.toBeFocused`] | Element is focused |
|
||||||
| [`method: LocatorAssertions.toBeHidden`] | Element is not visible |
|
| [`method: LocatorAssertions.toBeHidden`] | Element is not visible |
|
||||||
|
| [`method: LocatorAssertions.toBeInViewport`] | Element intersects viewport |
|
||||||
| [`method: LocatorAssertions.toBeVisible`] | Element is visible |
|
| [`method: LocatorAssertions.toBeVisible`] | Element is visible |
|
||||||
| [`method: LocatorAssertions.toContainText`] | Element contains text |
|
| [`method: LocatorAssertions.toContainText`] | Element contains text |
|
||||||
| [`method: LocatorAssertions.toHaveAttribute`] | Element has a DOM attribute |
|
| [`method: LocatorAssertions.toHaveAttribute`] | Element has a DOM attribute |
|
||||||
|
|
|
||||||
|
|
@ -1586,6 +1586,7 @@ scheme.FrameExpectParams = tObject({
|
||||||
expectedText: tOptional(tArray(tType('ExpectedTextValue'))),
|
expectedText: tOptional(tArray(tType('ExpectedTextValue'))),
|
||||||
expectedNumber: tOptional(tNumber),
|
expectedNumber: tOptional(tNumber),
|
||||||
expectedValue: tOptional(tType('SerializedArgument')),
|
expectedValue: tOptional(tType('SerializedArgument')),
|
||||||
|
viewportRatio: tOptional(tNumber),
|
||||||
useInnerText: tOptional(tBoolean),
|
useInnerText: tOptional(tBoolean),
|
||||||
isNot: tBoolean,
|
isNot: tBoolean,
|
||||||
timeout: tOptional(tNumber),
|
timeout: tOptional(tNumber),
|
||||||
|
|
|
||||||
|
|
@ -1422,7 +1422,7 @@ export class Frame extends SdkObject {
|
||||||
const injected = await context.injectedScript();
|
const injected = await context.injectedScript();
|
||||||
progress.throwIfAborted();
|
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 elements = info ? injected.querySelectorAll(info.parsed, document) : [];
|
||||||
const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array');
|
const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array');
|
||||||
let log = '';
|
let log = '';
|
||||||
|
|
@ -1434,7 +1434,7 @@ export class Frame extends SdkObject {
|
||||||
log = ` locator resolved to ${injected.previewNode(elements[0])}`;
|
log = ` locator resolved to ${injected.previewNode(elements[0])}`;
|
||||||
if (snapshotName)
|
if (snapshotName)
|
||||||
injected.markTargetElements(new Set(elements), 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 });
|
}, { info, options, snapshotName: progress.metadata.afterSnapshot });
|
||||||
|
|
||||||
if (log)
|
if (log)
|
||||||
|
|
|
||||||
|
|
@ -1119,7 +1119,7 @@ export class InjectedScript {
|
||||||
this.onGlobalListenersRemoved.add(addHitTargetInterceptorListeners);
|
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');
|
const isArray = options.expression === 'to.have.count' || options.expression.endsWith('.array');
|
||||||
if (isArray)
|
if (isArray)
|
||||||
return this.expectArray(elements, options);
|
return this.expectArray(elements, options);
|
||||||
|
|
@ -1130,13 +1130,16 @@ export class InjectedScript {
|
||||||
// expect(locator).not.toBeVisible() passes when there is no element.
|
// expect(locator).not.toBeVisible() passes when there is no element.
|
||||||
if (options.isNot && options.expression === 'to.be.visible')
|
if (options.isNot && options.expression === 'to.be.visible')
|
||||||
return { matches: false };
|
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.
|
// When none of the above applies, expect does not match.
|
||||||
return { matches: options.isNot };
|
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;
|
const expression = options.expression;
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
@ -1184,6 +1187,13 @@ export class InjectedScript {
|
||||||
return { received, matches };
|
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
|
// Multi-Select/Combobox
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
toBeEnabled,
|
toBeEnabled,
|
||||||
toBeFocused,
|
toBeFocused,
|
||||||
toBeHidden,
|
toBeHidden,
|
||||||
|
toBeInViewport,
|
||||||
toBeOK,
|
toBeOK,
|
||||||
toBeVisible,
|
toBeVisible,
|
||||||
toContainText,
|
toContainText,
|
||||||
|
|
@ -129,6 +130,7 @@ const customMatchers = {
|
||||||
toBeEnabled,
|
toBeEnabled,
|
||||||
toBeFocused,
|
toBeFocused,
|
||||||
toBeHidden,
|
toBeHidden,
|
||||||
|
toBeInViewport,
|
||||||
toBeOK,
|
toBeOK,
|
||||||
toBeVisible,
|
toBeVisible,
|
||||||
toContainText,
|
toContainText,
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,16 @@ export function toBeVisible(
|
||||||
}, options);
|
}, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toBeInViewport(
|
||||||
|
this: ReturnType<Expect['getState']>,
|
||||||
|
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(
|
export function toContainText(
|
||||||
this: ReturnType<Expect['getState']>,
|
this: ReturnType<Expect['getState']>,
|
||||||
locator: LocatorEx,
|
locator: LocatorEx,
|
||||||
|
|
|
||||||
31
packages/playwright-test/types/test.d.ts
vendored
31
packages/playwright-test/types/test.d.ts
vendored
|
|
@ -4480,6 +4480,37 @@ interface LocatorAssertions {
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
}): Promise<void>;
|
}): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensures that [Locator] points to an [attached](https://playwright.dev/docs/actionability#attached) and
|
* Ensures that [Locator] points to an [attached](https://playwright.dev/docs/actionability#attached) and
|
||||||
* [visible](https://playwright.dev/docs/actionability#visible) DOM node.
|
* [visible](https://playwright.dev/docs/actionability#visible) DOM node.
|
||||||
|
|
|
||||||
|
|
@ -2844,6 +2844,7 @@ export type FrameExpectParams = {
|
||||||
expectedText?: ExpectedTextValue[],
|
expectedText?: ExpectedTextValue[],
|
||||||
expectedNumber?: number,
|
expectedNumber?: number,
|
||||||
expectedValue?: SerializedArgument,
|
expectedValue?: SerializedArgument,
|
||||||
|
viewportRatio?: number,
|
||||||
useInnerText?: boolean,
|
useInnerText?: boolean,
|
||||||
isNot: boolean,
|
isNot: boolean,
|
||||||
timeout?: number,
|
timeout?: number,
|
||||||
|
|
@ -2853,6 +2854,7 @@ export type FrameExpectOptions = {
|
||||||
expectedText?: ExpectedTextValue[],
|
expectedText?: ExpectedTextValue[],
|
||||||
expectedNumber?: number,
|
expectedNumber?: number,
|
||||||
expectedValue?: SerializedArgument,
|
expectedValue?: SerializedArgument,
|
||||||
|
viewportRatio?: number,
|
||||||
useInnerText?: boolean,
|
useInnerText?: boolean,
|
||||||
timeout?: number,
|
timeout?: number,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2144,6 +2144,7 @@ Frame:
|
||||||
items: ExpectedTextValue
|
items: ExpectedTextValue
|
||||||
expectedNumber: number?
|
expectedNumber: number?
|
||||||
expectedValue: SerializedArgument?
|
expectedValue: SerializedArgument?
|
||||||
|
viewportRatio: number?
|
||||||
useInnerText: boolean?
|
useInnerText: boolean?
|
||||||
isNot: boolean
|
isNot: boolean
|
||||||
timeout: number?
|
timeout: number?
|
||||||
|
|
|
||||||
|
|
@ -285,3 +285,56 @@ test.describe('toHaveId', () => {
|
||||||
await expect(locator).toHaveId('node');
|
await expect(locator).toHaveId('node');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('toBeInViewport', () => {
|
||||||
|
test('should work', async ({ page }) => {
|
||||||
|
await page.setContent(`
|
||||||
|
<div id=big style="height: 10000px;"></div>
|
||||||
|
<div id=small>foo</div>
|
||||||
|
`);
|
||||||
|
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(`
|
||||||
|
<style>body, div, html { padding: 0; margin: 0; }</style>
|
||||||
|
<div id=big style="height: 400vh;"></div>
|
||||||
|
`);
|
||||||
|
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(`
|
||||||
|
<h1>hello</h1>
|
||||||
|
<div style="position: relative; height: 10000px; top: -5000px;></div>
|
||||||
|
`);
|
||||||
|
await expect(page.locator('h1')).toBeInViewport();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue