feat(expect): introduce expect(locator).toIntersectViewport() (#19901)
This is a new web-first assertion that should be used like this:
```ts
test('should work', async ({ page }) => {
const locator = page.locator('body');
// New web-first assertion.
await expect(locator).toIntersectViewport();
// The same functionality.
await expect.poll(() => locator.viewportRatio()).toBeGreaterThan(0);
});
```
Fixes #8740
This commit is contained in:
parent
1385007185
commit
2a49c5e498
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -383,6 +383,19 @@ export class InjectedScript {
|
|||
return isElementVisible(element);
|
||||
}
|
||||
|
||||
async viewportRatio(element: Element): Promise<number> {
|
||||
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<T>(predicate: Predicate<T>): InjectedScriptPoll<T> {
|
||||
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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -119,6 +119,16 @@ export function toBeVisible(
|
|||
}, options);
|
||||
}
|
||||
|
||||
export function toIntersectViewport(
|
||||
this: ReturnType<Expect['getState']>,
|
||||
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<Expect['getState']>,
|
||||
locator: LocatorEx,
|
||||
|
|
@ -342,4 +352,4 @@ export async function toPass(
|
|||
return { message, pass: this.isNot };
|
||||
}
|
||||
return { pass: !this.isNot, message: () => '' };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
packages/playwright-test/types/test.d.ts
vendored
20
packages/playwright-test/types/test.d.ts
vendored
|
|
@ -4641,6 +4641,26 @@ interface LocatorAssertions {
|
|||
*/
|
||||
timeout?: number;
|
||||
}): 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');
|
||||
* await expect(locator).toIntersectViewport();
|
||||
* ```
|
||||
*
|
||||
* @param options
|
||||
*/
|
||||
toIntersectViewport(options?: {
|
||||
/**
|
||||
* Time to retry the assertion for. Defaults to `timeout` in `TestConfig.expect`.
|
||||
*/
|
||||
timeout?: number;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -285,3 +285,38 @@ test.describe('toHaveId', () => {
|
|||
await expect(locator).toHaveId('node');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('toIntersectViewport', () => {
|
||||
test('should work', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<div id=big style="height: 10000px;"></div>
|
||||
<span id=small>foo</span>
|
||||
`);
|
||||
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(`
|
||||
<h1>hello</h1>
|
||||
<div style="position: relative; height: 10000px; top: -5000px;></div>
|
||||
`);
|
||||
await expect(page.locator('h1')).toIntersectViewport();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue