chore: implement typed Locators, expects, and internal contexts (#10573)

This commit is contained in:
jrandolf 2023-07-19 17:39:38 +02:00 committed by GitHub
parent dcc08aa6b8
commit c14f9b64a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 458 additions and 229 deletions

View File

@ -97,6 +97,7 @@ sidebar_label: API
| [LaunchOptions](./puppeteer.launchoptions.md) | Generic launch options that can be passed when launching any browser. | | [LaunchOptions](./puppeteer.launchoptions.md) | Generic launch options that can be passed when launching any browser. |
| [LocatorEventObject](./puppeteer.locatoreventobject.md) | | | [LocatorEventObject](./puppeteer.locatoreventobject.md) | |
| [LocatorOptions](./puppeteer.locatoroptions.md) | | | [LocatorOptions](./puppeteer.locatoroptions.md) | |
| [LocatorScrollOptions](./puppeteer.locatorscrolloptions.md) | |
| [MediaFeature](./puppeteer.mediafeature.md) | | | [MediaFeature](./puppeteer.mediafeature.md) | |
| [Metrics](./puppeteer.metrics.md) | | | [Metrics](./puppeteer.metrics.md) | |
| [MouseClickOptions](./puppeteer.mouseclickoptions.md) | | | [MouseClickOptions](./puppeteer.mouseclickoptions.md) | |
@ -147,7 +148,6 @@ sidebar_label: API
| Type Alias | Description | | Type Alias | Description |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [ActionCondition](./puppeteer.actioncondition.md) | |
| [ActionResult](./puppeteer.actionresult.md) | | | [ActionResult](./puppeteer.actionresult.md) | |
| [Awaitable](./puppeteer.awaitable.md) | | | [Awaitable](./puppeteer.awaitable.md) | |
| [AwaitableIterable](./puppeteer.awaitableiterable.md) | | | [AwaitableIterable](./puppeteer.awaitableiterable.md) | |
@ -167,11 +167,13 @@ sidebar_label: API
| [InterceptResolutionStrategy](./puppeteer.interceptresolutionstrategy.md) | | | [InterceptResolutionStrategy](./puppeteer.interceptresolutionstrategy.md) | |
| [KeyInput](./puppeteer.keyinput.md) | All the valid keys that can be passed to functions that take user input, such as [keyboard.press](./puppeteer.keyboard.press.md) | | [KeyInput](./puppeteer.keyinput.md) | All the valid keys that can be passed to functions that take user input, such as [keyboard.press](./puppeteer.keyboard.press.md) |
| [KeyPressOptions](./puppeteer.keypressoptions.md) | | | [KeyPressOptions](./puppeteer.keypressoptions.md) | |
| [LocatorClickOptions](./puppeteer.locatorclickoptions.md) | |
| [LowerCasePaperFormat](./puppeteer.lowercasepaperformat.md) | | | [LowerCasePaperFormat](./puppeteer.lowercasepaperformat.md) | |
| [MouseButton](./puppeteer.mousebutton.md) | | | [MouseButton](./puppeteer.mousebutton.md) | |
| [NodeFor](./puppeteer.nodefor.md) | | | [NodeFor](./puppeteer.nodefor.md) | |
| [PaperFormat](./puppeteer.paperformat.md) | All the valid paper format types when printing a PDF. | | [PaperFormat](./puppeteer.paperformat.md) | All the valid paper format types when printing a PDF. |
| [Permission](./puppeteer.permission.md) | | | [Permission](./puppeteer.permission.md) | |
| [Predicate](./puppeteer.predicate.md) | |
| [Product](./puppeteer.product.md) | Supported products. | | [Product](./puppeteer.product.md) | Supported products. |
| [ProtocolLifeCycleEvent](./puppeteer.protocollifecycleevent.md) | | | [ProtocolLifeCycleEvent](./puppeteer.protocollifecycleevent.md) | |
| [PuppeteerLifeCycleEvent](./puppeteer.puppeteerlifecycleevent.md) | | | [PuppeteerLifeCycleEvent](./puppeteer.puppeteerlifecycleevent.md) | |

View File

@ -1,16 +0,0 @@
---
sidebar_label: ActionCondition
---
# ActionCondition type
#### Signature:
```typescript
export type ActionCondition = (
element: ElementHandle,
signal: AbortSignal
) => Promise<void>;
```
**References:** [ElementHandle](./puppeteer.elementhandle.md)

View File

@ -12,7 +12,6 @@ export interface ActionOptions
## Properties ## Properties
| Property | Modifiers | Type | Description | Default | | Property | Modifiers | Type | Description | Default |
| ---------- | --------------------- | ----------------------------------------------------- | ----------- | ------- | | -------- | --------------------- | ----------- | ----------- | ------- |
| conditions | | [ActionCondition](./puppeteer.actioncondition.md)\[\] | | | | signal | <code>optional</code> | AbortSignal | | |
| signal | <code>optional</code> | AbortSignal | | |

View File

@ -10,19 +10,21 @@ Creates a locator for the provided `selector`. See [Locator](./puppeteer.locator
```typescript ```typescript
class Frame { class Frame {
locator(selector: string): Locator; locator<Selector extends string>(
selector: Selector
): Locator<NodeFor<Selector>>;
} }
``` ```
## Parameters ## Parameters
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ------ | ----------- | | --------- | -------- | ----------- |
| selector | string | | | selector | Selector | |
**Returns:** **Returns:**
[Locator](./puppeteer.locator.md) [Locator](./puppeteer.locator.md)&lt;[NodeFor](./puppeteer.nodefor.md)&lt;Selector&gt;&gt;
## Remarks ## Remarks

View File

@ -8,19 +8,19 @@ sidebar_label: Locator.click
```typescript ```typescript
class Locator { class Locator {
abstract click( abstract click<ElementType extends Element>(
clickOptions?: ClickOptions & { this: Locator<ElementType>,
signal?: AbortSignal; options?: Readonly<LocatorClickOptions>
}
): Promise<void>; ): Promise<void>;
} }
``` ```
## Parameters ## Parameters
| Parameter | Type | Description | | Parameter | Type | Description |
| ------------ | --------------------------------------------------------------------------- | ------------ | | --------- | ------------------------------------------------------------------------- | ------------ |
| clickOptions | [ClickOptions](./puppeteer.clickoptions.md) &amp; { signal?: AbortSignal; } | _(Optional)_ | | this | [Locator](./puppeteer.locator.md)&lt;ElementType&gt; | |
| options | Readonly&lt;[LocatorClickOptions](./puppeteer.locatorclickoptions.md)&gt; | _(Optional)_ |
**Returns:** **Returns:**

View File

@ -10,21 +10,21 @@ Fills out the input identified by the locator using the provided value. The type
```typescript ```typescript
class Locator { class Locator {
abstract fill( abstract fill<ElementType extends Element>(
this: Locator<ElementType>,
value: string, value: string,
fillOptions?: { options?: Readonly<ActionOptions>
signal?: AbortSignal;
}
): Promise<void>; ): Promise<void>;
} }
``` ```
## Parameters ## Parameters
| Parameter | Type | Description | | Parameter | Type | Description |
| ----------- | ------------------------- | ------------ | | --------- | ------------------------------------------------------------- | ------------ |
| value | string | | | this | [Locator](./puppeteer.locator.md)&lt;ElementType&gt; | |
| fillOptions | { signal?: AbortSignal; } | _(Optional)_ | | value | string | |
| options | Readonly&lt;[ActionOptions](./puppeteer.actionoptions.md)&gt; | _(Optional)_ |
**Returns:** **Returns:**

View File

@ -8,15 +8,19 @@ sidebar_label: Locator.hover
```typescript ```typescript
class Locator { class Locator {
abstract hover(hoverOptions?: {signal?: AbortSignal}): Promise<void>; abstract hover<ElementType extends Element>(
this: Locator<ElementType>,
options?: Readonly<ActionOptions>
): Promise<void>;
} }
``` ```
## Parameters ## Parameters
| Parameter | Type | Description | | Parameter | Type | Description |
| ------------ | ------------------------- | ------------ | | --------- | ------------------------------------------------------------- | ------------ |
| hoverOptions | { signal?: AbortSignal; } | _(Optional)_ | | this | [Locator](./puppeteer.locator.md)&lt;ElementType&gt; | |
| options | Readonly&lt;[ActionOptions](./puppeteer.actionoptions.md)&gt; | _(Optional)_ |
**Returns:** **Returns:**

View File

@ -9,23 +9,29 @@ Locators describe a strategy of locating elements and performing an action on th
#### Signature: #### Signature:
```typescript ```typescript
export declare abstract class Locator extends EventEmitter export declare abstract class Locator<T> extends EventEmitter
``` ```
**Extends:** [EventEmitter](./puppeteer.eventemitter.md) **Extends:** [EventEmitter](./puppeteer.eventemitter.md)
## Properties
| Property | Modifiers | Type | Description |
| -------- | --------------------- | ---- | ------------------------------------------------------------ |
| \_ | <code>optional</code> | T | Used for nominally typing [Locator](./puppeteer.locator.md). |
## Methods ## Methods
| Method | Modifiers | Description | | Method | Modifiers | Description |
| ------------------------------------------------------------------------------------------------ | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------------------------------------------------------ | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [click(clickOptions)](./puppeteer.locator.click.md) | | | | [click(this, options)](./puppeteer.locator.click.md) | | |
| [fill(value, fillOptions)](./puppeteer.locator.fill.md) | | Fills out the input identified by the locator using the provided value. The type of the input is determined at runtime and the appropriate fill-out method is chosen based on the type. contenteditable, selector, inputs are supported. | | [fill(this, value, options)](./puppeteer.locator.fill.md) | | Fills out the input identified by the locator using the provided value. The type of the input is determined at runtime and the appropriate fill-out method is chosen based on the type. contenteditable, selector, inputs are supported. |
| [hover(hoverOptions)](./puppeteer.locator.hover.md) | | | | [hover(this, options)](./puppeteer.locator.hover.md) | | |
| [off(eventName, handler)](./puppeteer.locator.off.md) | | | | [off(eventName, handler)](./puppeteer.locator.off.md) | | |
| [on(eventName, handler)](./puppeteer.locator.on.md) | | | | [on(eventName, handler)](./puppeteer.locator.on.md) | | |
| [once(eventName, handler)](./puppeteer.locator.once.md) | | | | [once(eventName, handler)](./puppeteer.locator.once.md) | | |
| [race(locators)](./puppeteer.locator.race.md) | <code>static</code> | Creates a race between multiple locators but ensures that only a single one acts. | | [race(locators)](./puppeteer.locator.race.md) | <code>static</code> | Creates a race between multiple locators but ensures that only a single one acts. |
| [scroll(scrollOptions)](./puppeteer.locator.scroll.md) | | | | [scroll(this, options)](./puppeteer.locator.scroll.md) | | |
| [setEnsureElementIsInTheViewport(value)](./puppeteer.locator.setensureelementisintheviewport.md) | | | | [setEnsureElementIsInTheViewport(value)](./puppeteer.locator.setensureelementisintheviewport.md) | | |
| [setTimeout(timeout)](./puppeteer.locator.settimeout.md) | | | | [setTimeout(timeout)](./puppeteer.locator.settimeout.md) | | |
| [setVisibility(visibility)](./puppeteer.locator.setvisibility.md) | | | | [setVisibility(visibility)](./puppeteer.locator.setvisibility.md) | | |

View File

@ -10,16 +10,18 @@ Creates a race between multiple locators but ensures that only a single one acts
```typescript ```typescript
class Locator { class Locator {
static race(locators: Locator[]): Locator; static race<Locators extends Array<Locator<unknown>>>(
locators: Locators
): Locator<UnionLocatorOf<Locators>>;
} }
``` ```
## Parameters ## Parameters
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ------------------------------------- | ----------- | | --------- | -------- | ----------- |
| locators | [Locator](./puppeteer.locator.md)\[\] | | | locators | Locators | |
**Returns:** **Returns:**
[Locator](./puppeteer.locator.md) [Locator](./puppeteer.locator.md)&lt;UnionLocatorOf&lt;Locators&gt;&gt;

View File

@ -8,19 +8,19 @@ sidebar_label: Locator.scroll
```typescript ```typescript
class Locator { class Locator {
abstract scroll(scrollOptions?: { abstract scroll<ElementType extends Element>(
scrollTop?: number; this: Locator<ElementType>,
scrollLeft?: number; options?: Readonly<LocatorScrollOptions>
signal?: AbortSignal; ): Promise<void>;
}): Promise<void>;
} }
``` ```
## Parameters ## Parameters
| Parameter | Type | Description | | Parameter | Type | Description |
| ------------- | ------------------------------------------------------------------ | ------------ | | --------- | --------------------------------------------------------------------------- | ------------ |
| scrollOptions | { scrollTop?: number; scrollLeft?: number; signal?: AbortSignal; } | _(Optional)_ | | this | [Locator](./puppeteer.locator.md)&lt;ElementType&gt; | |
| options | Readonly&lt;[LocatorScrollOptions](./puppeteer.locatorscrolloptions.md)&gt; | _(Optional)_ |
**Returns:** **Returns:**

View File

@ -0,0 +1,13 @@
---
sidebar_label: LocatorClickOptions
---
# LocatorClickOptions type
#### Signature:
```typescript
export type LocatorClickOptions = ClickOptions & ActionOptions;
```
**References:** [ClickOptions](./puppeteer.clickoptions.md), [ActionOptions](./puppeteer.actionoptions.md)

View File

@ -0,0 +1,20 @@
---
sidebar_label: LocatorScrollOptions
---
# LocatorScrollOptions interface
#### Signature:
```typescript
export interface LocatorScrollOptions extends ActionOptions
```
**Extends:** [ActionOptions](./puppeteer.actionoptions.md)
## Properties
| Property | Modifiers | Type | Description | Default |
| ---------- | --------------------- | ------ | ----------- | ------- |
| scrollLeft | <code>optional</code> | number | | |
| scrollTop | <code>optional</code> | number | | |

View File

@ -10,19 +10,21 @@ Creates a locator for the provided `selector`. See [Locator](./puppeteer.locator
```typescript ```typescript
class Page { class Page {
locator(selector: string): Locator; locator<Selector extends string>(
selector: Selector
): Locator<NodeFor<Selector>>;
} }
``` ```
## Parameters ## Parameters
| Parameter | Type | Description | | Parameter | Type | Description |
| --------- | ------ | ----------- | | --------- | -------- | ----------- |
| selector | string | | | selector | Selector | |
**Returns:** **Returns:**
[Locator](./puppeteer.locator.md) [Locator](./puppeteer.locator.md)&lt;[NodeFor](./puppeteer.nodefor.md)&lt;Selector&gt;&gt;
## Remarks ## Remarks

View File

@ -0,0 +1,15 @@
---
sidebar_label: Predicate
---
# Predicate type
#### Signature:
```typescript
export type Predicate<From, To extends From = From> =
| ((value: From) => value is To)
| ((value: From) => Awaitable<boolean>);
```
**References:** [Awaitable](./puppeteer.awaitable.md)

View File

@ -424,7 +424,9 @@ export class Frame {
* Locators API is experimental and we will not follow semver for breaking * Locators API is experimental and we will not follow semver for breaking
* change in the Locators API. * change in the Locators API.
*/ */
locator(selector: string): Locator { locator<Selector extends string>(
selector: Selector
): Locator<NodeFor<Selector>> {
return Locator.create(this, selector); return Locator.create(this, selector);
} }

View File

@ -16,13 +16,20 @@
import {TimeoutError} from '../common/Errors.js'; import {TimeoutError} from '../common/Errors.js';
import {EventEmitter} from '../common/EventEmitter.js'; import {EventEmitter} from '../common/EventEmitter.js';
import {Awaitable, HandleFor, NodeFor} from '../common/types.js';
import {debugError} from '../common/util.js'; import {debugError} from '../common/util.js';
import {isErrorLike} from '../util/ErrorLike.js'; import {isErrorLike} from '../util/ErrorLike.js';
import {ElementHandle, BoundingBox, ClickOptions} from './ElementHandle.js'; import {BoundingBox, ClickOptions, ElementHandle} from './ElementHandle.js';
import type {Frame} from './Frame.js'; import type {Frame} from './Frame.js';
import type {Page} from './Page.js'; import type {Page} from './Page.js';
interface LocatorContext<T> {
conditions?: Set<ActionCondition<T>>;
}
const LOCATOR_CONTEXTS = new WeakMap<Locator<unknown>, LocatorContext<never>>();
/** /**
* @public * @public
*/ */
@ -74,19 +81,38 @@ const CONDITION_TIMEOUT = 1_000;
const WAIT_FOR_FUNCTION_DELAY = 100; const WAIT_FOR_FUNCTION_DELAY = 100;
/** /**
* @public * @internal
*/ */
export type ActionCondition = ( export type ActionCondition<T> = (
element: ElementHandle, element: HandleFor<T>,
signal: AbortSignal signal: AbortSignal
) => Promise<void>; ) => Promise<void>;
/**
* @public
*/
export type Predicate<From, To extends From = From> =
| ((value: From) => value is To)
| ((value: From) => Awaitable<boolean>);
/** /**
* @public * @public
*/ */
export interface ActionOptions { export interface ActionOptions {
signal?: AbortSignal; signal?: AbortSignal;
conditions: ActionCondition[]; }
/**
* @public
*/
export type LocatorClickOptions = ClickOptions & ActionOptions;
/**
* @public
*/
export interface LocatorScrollOptions extends ActionOptions {
scrollTop?: number;
scrollLeft?: number;
} }
/** /**
@ -108,6 +134,8 @@ export interface LocatorEventObject {
[LocatorEmittedEvents.Action]: never; [LocatorEmittedEvents.Action]: never;
} }
type UnionLocatorOf<T> = T extends Array<Locator<infer S>> ? S : never;
/** /**
* Locators describe a strategy of locating elements and performing an action on * Locators describe a strategy of locating elements and performing an action on
* them. If the action fails because the element is not ready for the action, * them. If the action fails because the element is not ready for the action,
@ -116,12 +144,20 @@ export interface LocatorEventObject {
* *
* @public * @public
*/ */
export abstract class Locator extends EventEmitter { export abstract class Locator<T> extends EventEmitter {
/**
* Used for nominally typing {@link Locator}.
*/
declare _?: T;
/** /**
* @internal * @internal
*/ */
static create(pageOrFrame: Page | Frame, selector: string): Locator { static create<Selector extends string>(
return new LocatorImpl(pageOrFrame, selector).setTimeout( pageOrFrame: Page | Frame,
selector: Selector
): Locator<NodeFor<Selector>> {
return new NodeLocator<NodeFor<Selector>>(pageOrFrame, selector).setTimeout(
'getDefaultTimeout' in pageOrFrame 'getDefaultTimeout' in pageOrFrame
? pageOrFrame.getDefaultTimeout() ? pageOrFrame.getDefaultTimeout()
: pageOrFrame.page().getDefaultTimeout() : pageOrFrame.page().getDefaultTimeout()
@ -132,8 +168,23 @@ export abstract class Locator extends EventEmitter {
* Creates a race between multiple locators but ensures that only a single one * Creates a race between multiple locators but ensures that only a single one
* acts. * acts.
*/ */
static race(locators: Locator[]): Locator { static race<Locators extends Array<Locator<unknown>>>(
return new RaceLocatorImpl(locators); locators: Locators
): Locator<UnionLocatorOf<Locators>> {
return new RaceLocator(
locators as Array<Locator<UnionLocatorOf<Locators>>>
);
}
/**
* Creates an expectation that is evaluated against located values.
*
* If the expectations do not match, then the locator will retry.
*
* @internal
*/
expect<S extends T>(predicate: Predicate<T, S>): Locator<S> {
return new ExpectedLocator(this, predicate);
} }
override on<K extends keyof LocatorEventObject>( override on<K extends keyof LocatorEventObject>(
@ -167,10 +218,9 @@ export abstract class Locator extends EventEmitter {
abstract setWaitForStableBoundingBox(value: boolean): this; abstract setWaitForStableBoundingBox(value: boolean): this;
abstract click( abstract click<ElementType extends Element>(
clickOptions?: ClickOptions & { this: Locator<ElementType>,
signal?: AbortSignal; options?: Readonly<LocatorClickOptions>
}
): Promise<void>; ): Promise<void>;
/** /**
@ -179,24 +229,27 @@ export abstract class Locator extends EventEmitter {
* method is chosen based on the type. contenteditable, selector, inputs are * method is chosen based on the type. contenteditable, selector, inputs are
* supported. * supported.
*/ */
abstract fill( abstract fill<ElementType extends Element>(
this: Locator<ElementType>,
value: string, value: string,
fillOptions?: {signal?: AbortSignal} options?: Readonly<ActionOptions>
): Promise<void>; ): Promise<void>;
abstract hover(hoverOptions?: {signal?: AbortSignal}): Promise<void>; abstract hover<ElementType extends Element>(
this: Locator<ElementType>,
options?: Readonly<ActionOptions>
): Promise<void>;
abstract scroll(scrollOptions?: { abstract scroll<ElementType extends Element>(
scrollTop?: number; this: Locator<ElementType>,
scrollLeft?: number; options?: Readonly<LocatorScrollOptions>
signal?: AbortSignal; ): Promise<void>;
}): Promise<void>;
} }
/** /**
* @internal * @internal
*/ */
export class LocatorImpl extends Locator { export class NodeLocator<T extends Node> extends Locator<T> {
#pageOrFrame: Page | Frame; #pageOrFrame: Page | Frame;
#selector: string; #selector: string;
#visibility: VisibilityOption = 'visible'; #visibility: VisibilityOption = 'visible';
@ -302,8 +355,8 @@ export class LocatorImpl extends Locator {
/** /**
* Checks if the element is in the viewport and auto-scrolls it if it is not. * Checks if the element is in the viewport and auto-scrolls it if it is not.
*/ */
#ensureElementIsInTheViewportIfNeeded = async ( #ensureElementIsInTheViewportIfNeeded = async <ElementType extends Element>(
element: ElementHandle, element: HandleFor<ElementType>,
signal?: AbortSignal signal?: AbortSignal
): Promise<void> => { ): Promise<void> => {
if (!this.#ensureElementIsInTheViewport) { if (!this.#ensureElementIsInTheViewport) {
@ -332,8 +385,8 @@ export class LocatorImpl extends Locator {
* than 'hidden' or 'collapse' and non-empty bounding box. visibility === * than 'hidden' or 'collapse' and non-empty bounding box. visibility ===
* 'hidden' means the opposite of that. * 'hidden' means the opposite of that.
*/ */
#waitForVisibilityIfNeeded = async ( #waitForVisibilityIfNeeded = async <ElementType extends Element>(
element: ElementHandle, element: HandleFor<ElementType>,
signal?: AbortSignal signal?: AbortSignal
): Promise<void> => { ): Promise<void> => {
if (this.#visibility === null) { if (this.#visibility === null) {
@ -350,11 +403,11 @@ export class LocatorImpl extends Locator {
}; };
/** /**
* If the element is a button, textarea, input or select, wait till the * If the element has a "disabled" property, wait for the element to be
* element becomes enabled. * enabled.
*/ */
#waitForEnabledIfNeeded = async ( #waitForEnabledIfNeeded = async <ElementType extends Element>(
element: ElementHandle, element: HandleFor<ElementType>,
signal?: AbortSignal signal?: AbortSignal
): Promise<void> => { ): Promise<void> => {
if (!this.#waitForEnabled) { if (!this.#waitForEnabled) {
@ -362,8 +415,8 @@ export class LocatorImpl extends Locator {
} }
await this.#pageOrFrame.waitForFunction( await this.#pageOrFrame.waitForFunction(
el => { el => {
if (['button', 'textarea', 'input', 'select'].includes(el.tagName)) { if ('disabled' in el && typeof el.disabled === 'boolean') {
return !(el as HTMLInputElement).disabled; return !el.disabled;
} }
return true; return true;
}, },
@ -379,8 +432,8 @@ export class LocatorImpl extends Locator {
* Compares the bounding box of the element for two consecutive animation * Compares the bounding box of the element for two consecutive animation
* frames and waits till they are the same. * frames and waits till they are the same.
*/ */
#waitForStableBoundingBoxIfNeeded = async ( #waitForStableBoundingBoxIfNeeded = async <ElementType extends Element>(
element: ElementHandle, element: HandleFor<ElementType>,
signal?: AbortSignal signal?: AbortSignal
): Promise<void> => { ): Promise<void> => {
if (!this.#waitForStableBoundingBox) { if (!this.#waitForStableBoundingBox) {
@ -423,21 +476,26 @@ export class LocatorImpl extends Locator {
}, signal); }, signal);
}; };
async #run( #run(
action: (el: ElementHandle) => Promise<void>, action: (el: HandleFor<T>) => Promise<void>,
options?: ActionOptions signal?: AbortSignal,
conditions: Array<ActionCondition<T>> = []
) { ) {
await this.#waitForFunction( const globalConditions = [
...(LOCATOR_CONTEXTS.get(this)?.conditions?.values() ?? []),
] as Array<ActionCondition<T>>;
const allConditions = conditions.concat(globalConditions);
return this.#waitForFunction(
async signal => { async signal => {
// 1. Select the element without visibility checks. // 1. Select the element without visibility checks.
const element = await this.#pageOrFrame.waitForSelector( const element = (await this.#pageOrFrame.waitForSelector(
this.#selector, this.#selector,
{ {
visible: false, visible: false,
timeout: this.#timeout, timeout: this.#timeout,
signal, signal,
} }
); )) as HandleFor<T> | null;
// Retry if no element is found. // Retry if no element is found.
if (!element) { if (!element) {
return false; return false;
@ -446,9 +504,9 @@ export class LocatorImpl extends Locator {
signal?.throwIfAborted(); signal?.throwIfAborted();
// 2. Perform action specific checks. // 2. Perform action specific checks.
await Promise.all( await Promise.all(
options?.conditions.map(check => { allConditions.map(check => {
return check(element, signal); return check(element, signal);
}) || [] })
); );
signal?.throwIfAborted(); signal?.throwIfAborted();
// 3. Perform the action // 3. Perform the action
@ -459,29 +517,26 @@ export class LocatorImpl extends Locator {
void element.dispose().catch(debugError); void element.dispose().catch(debugError);
} }
}, },
options?.signal, signal,
this.#timeout this.#timeout
); );
} }
async click( async click<ElementType extends Element>(
clickOptions?: ClickOptions & { this: NodeLocator<ElementType>,
signal?: AbortSignal; options?: Readonly<LocatorClickOptions>
}
): Promise<void> { ): Promise<void> {
return await this.#run( return await this.#run(
async element => { async element => {
await element.click(clickOptions); await element.click(options);
}, },
{ options?.signal,
signal: clickOptions?.signal, [
conditions: [ this.#ensureElementIsInTheViewportIfNeeded,
this.#ensureElementIsInTheViewportIfNeeded, this.#waitForVisibilityIfNeeded,
this.#waitForVisibilityIfNeeded, this.#waitForEnabledIfNeeded,
this.#waitForEnabledIfNeeded, this.#waitForStableBoundingBoxIfNeeded,
this.#waitForStableBoundingBoxIfNeeded, ]
],
}
); );
} }
@ -491,13 +546,14 @@ export class LocatorImpl extends Locator {
* method is chosen based on the type. contenteditable, selector, inputs are * method is chosen based on the type. contenteditable, selector, inputs are
* supported. * supported.
*/ */
async fill( fill<ElementType extends Element>(
this: NodeLocator<ElementType>,
value: string, value: string,
fillOptions?: {signal?: AbortSignal} options?: Readonly<ActionOptions>
): Promise<void> { ): Promise<void> {
return await this.#run( return this.#run(
async element => { async element => {
const input = element as ElementHandle<HTMLElement>; const input = element as unknown as ElementHandle<HTMLElement>;
const inputType = await input.evaluate(el => { const inputType = await input.evaluate(el => {
if (el instanceof HTMLSelectElement) { if (el instanceof HTMLSelectElement) {
return 'select'; return 'select';
@ -583,40 +639,38 @@ export class LocatorImpl extends Locator {
throw new Error(`Element cannot be filled out.`); throw new Error(`Element cannot be filled out.`);
} }
}, },
{ options?.signal,
signal: fillOptions?.signal, [
conditions: [ this.#ensureElementIsInTheViewportIfNeeded,
this.#ensureElementIsInTheViewportIfNeeded, this.#waitForVisibilityIfNeeded,
this.#waitForVisibilityIfNeeded, this.#waitForEnabledIfNeeded,
this.#waitForEnabledIfNeeded, this.#waitForStableBoundingBoxIfNeeded,
this.#waitForStableBoundingBoxIfNeeded, ]
],
}
); );
} }
async hover(hoverOptions?: {signal?: AbortSignal}): Promise<void> { hover<ElementType extends Element>(
return await this.#run( this: NodeLocator<ElementType>,
options?: Readonly<ActionOptions>
): Promise<void> {
return this.#run(
async element => { async element => {
await element.hover(); await element.hover();
}, },
{ options?.signal,
signal: hoverOptions?.signal, [
conditions: [ this.#ensureElementIsInTheViewportIfNeeded,
this.#ensureElementIsInTheViewportIfNeeded, this.#waitForVisibilityIfNeeded,
this.#waitForVisibilityIfNeeded, this.#waitForStableBoundingBoxIfNeeded,
this.#waitForStableBoundingBoxIfNeeded, ]
],
}
); );
} }
async scroll(scrollOptions?: { scroll<ElementType extends Element>(
scrollTop?: number; this: NodeLocator<ElementType>,
scrollLeft?: number; options?: Readonly<LocatorScrollOptions>
signal?: AbortSignal; ): Promise<void> {
}): Promise<void> { return this.#run(
return await this.#run(
async element => { async element => {
await element.evaluate( await element.evaluate(
(el, scrollTop, scrollLeft) => { (el, scrollTop, scrollLeft) => {
@ -627,29 +681,110 @@ export class LocatorImpl extends Locator {
el.scrollLeft = scrollLeft; el.scrollLeft = scrollLeft;
} }
}, },
scrollOptions?.scrollTop, options?.scrollTop,
scrollOptions?.scrollLeft options?.scrollLeft
); );
}, },
{ options?.signal,
signal: scrollOptions?.signal, [
conditions: [ this.#ensureElementIsInTheViewportIfNeeded,
this.#ensureElementIsInTheViewportIfNeeded, this.#waitForVisibilityIfNeeded,
this.#waitForVisibilityIfNeeded, this.#waitForStableBoundingBoxIfNeeded,
this.#waitForStableBoundingBoxIfNeeded, ]
],
}
); );
} }
} }
class ExpectedLocator<From, To extends From> extends Locator<To> {
#base: Locator<From>;
#predicate: Predicate<From, To>;
constructor(base: Locator<From>, predicate: Predicate<From, To>) {
super();
this.#base = base;
this.#predicate = predicate;
}
override setVisibility(visibility: VisibilityOption): this {
this.#base.setVisibility(visibility);
return this;
}
override setTimeout(timeout: number): this {
this.#base.setTimeout(timeout);
return this;
}
override setEnsureElementIsInTheViewport(value: boolean): this {
this.#base.setEnsureElementIsInTheViewport(value);
return this;
}
override setWaitForEnabled(value: boolean): this {
this.#base.setWaitForEnabled(value);
return this;
}
override setWaitForStableBoundingBox(value: boolean): this {
this.#base.setWaitForStableBoundingBox(value);
return this;
}
#condition: ActionCondition<From> = async (handle, signal) => {
// TODO(jrandolf): We should remove this once JSHandle has waitForFunction.
await (handle as ElementHandle<Node>).frame.waitForFunction(
this.#predicate,
{signal},
handle
);
};
#insertFilterCondition<
FromElement extends Node,
ToElement extends FromElement,
>(this: ExpectedLocator<FromElement, ToElement>): void {
const context = (LOCATOR_CONTEXTS.get(this.#base) ??
{}) as LocatorContext<FromElement>;
context.conditions ??= new Set();
context.conditions.add(this.#condition);
LOCATOR_CONTEXTS.set(this.#base, context);
}
override click<FromElement extends Element, ToElement extends FromElement>(
this: ExpectedLocator<FromElement, ToElement>,
options?: Readonly<LocatorClickOptions>
): Promise<void> {
this.#insertFilterCondition();
return this.#base.click(options);
}
override fill<FromElement extends Element, ToElement extends FromElement>(
this: ExpectedLocator<FromElement, ToElement>,
value: string,
options?: Readonly<ActionOptions>
): Promise<void> {
this.#insertFilterCondition();
return this.#base.fill(value, options);
}
override hover<FromElement extends Element, ToElement extends FromElement>(
this: ExpectedLocator<FromElement, ToElement>,
options?: Readonly<ActionOptions>
): Promise<void> {
this.#insertFilterCondition();
return this.#base.hover(options);
}
override scroll<FromElement extends Element, ToElement extends FromElement>(
this: ExpectedLocator<FromElement, ToElement>,
options?: Readonly<LocatorScrollOptions>
): Promise<void> {
this.#insertFilterCondition();
return this.#base.scroll(options);
}
}
/** /**
* @internal * @internal
*/ */
class RaceLocatorImpl extends Locator { class RaceLocator<T> extends Locator<T> {
#locators: Locator[]; #locators: Array<Locator<T>>;
constructor(locators: Locator[]) { constructor(locators: Array<Locator<T>>) {
super(); super();
this.#locators = locators; this.#locators = locators;
} }
@ -689,22 +824,20 @@ class RaceLocatorImpl extends Locator {
return this; return this;
} }
async #runRace( async #run(
action: (el: Locator, abortSignal: AbortSignal) => Promise<void>, action: (locator: Locator<T>, signal: AbortSignal) => Promise<void>,
options: { signal?: AbortSignal
signal?: AbortSignal;
}
) { ) {
const abortControllers = new WeakMap<Locator, AbortController>(); const abortControllers = new WeakMap<Locator<T>, AbortController>();
// Abort all locators if the user-provided signal aborts. // Abort all locators if the user-provided signal aborts.
options.signal?.addEventListener('abort', () => { signal?.addEventListener('abort', () => {
for (const locator of this.#locators) { for (const locator of this.#locators) {
abortControllers.get(locator)?.abort(); abortControllers.get(locator)?.abort();
} }
}); });
const handleLocatorAction = (locator: Locator): (() => void) => { const handleLocatorAction = (locator: Locator<T>): (() => void) => {
return () => { return () => {
// When one locator is ready to act, we will abort other locators. // When one locator is ready to act, we will abort other locators.
for (const other of this.#locators) { for (const other of this.#locators) {
@ -716,7 +849,7 @@ class RaceLocatorImpl extends Locator {
}; };
}; };
const createAbortController = (locator: Locator): AbortController => { const createAbortController = (locator: Locator<T>): AbortController => {
const abortController = new AbortController(); const abortController = new AbortController();
abortControllers.set(locator, abortController); abortControllers.set(locator, abortController);
return abortController; return abortController;
@ -731,7 +864,7 @@ class RaceLocatorImpl extends Locator {
}) })
); );
options.signal?.throwIfAborted(); signal?.throwIfAborted();
const rejected = results.filter( const rejected = results.filter(
(result): result is PromiseRejectedResult => { (result): result is PromiseRejectedResult => {
@ -754,70 +887,52 @@ class RaceLocatorImpl extends Locator {
} }
} }
override async click( async click<ElementType extends Element>(
clickOptions?: ClickOptions & { this: RaceLocator<ElementType>,
signal?: AbortSignal; options?: Readonly<LocatorClickOptions>
}
): Promise<void> { ): Promise<void> {
return await this.#runRace( return await this.#run(
(locator, abortSignal) => { (locator, signal) => {
return locator.click({ return locator.click({...options, signal});
...clickOptions,
signal: abortSignal,
});
}, },
{ options?.signal
signal: clickOptions?.signal,
}
); );
} }
override async fill( async fill<ElementType extends Element>(
this: RaceLocator<ElementType>,
value: string, value: string,
fillOptions?: {signal?: AbortSignal} options?: Readonly<ActionOptions>
): Promise<void> { ): Promise<void> {
return await this.#runRace( return await this.#run(
(locator, abortSignal) => { (locator, signal) => {
return locator.fill(value, { return locator.fill(value, {...options, signal});
...fillOptions,
signal: abortSignal,
});
}, },
{ options?.signal
signal: fillOptions?.signal,
}
); );
} }
override async hover(hoverOptions?: {signal?: AbortSignal}): Promise<void> { async hover<ElementType extends Element>(
return await this.#runRace( this: RaceLocator<ElementType>,
(locator, abortSignal) => { options?: Readonly<ActionOptions>
return locator.hover({ ): Promise<void> {
...hoverOptions, return await this.#run(
signal: abortSignal, (locator, signal) => {
}); return locator.hover({...options, signal});
}, },
{ options?.signal
signal: hoverOptions?.signal,
}
); );
} }
override async scroll(scrollOptions?: { async scroll<ElementType extends Element>(
scrollTop?: number; this: RaceLocator<ElementType>,
scrollLeft?: number; options?: Readonly<LocatorScrollOptions>
signal?: AbortSignal; ): Promise<void> {
}): Promise<void> { return await this.#run(
return await this.#runRace( (locator, signal) => {
(locator, abortSignal) => { return locator.scroll({...options, signal});
return locator.scroll({
...scrollOptions,
signal: abortSignal,
});
}, },
{ options?.signal
signal: scrollOptions?.signal,
}
); );
} }
} }

View File

@ -831,7 +831,9 @@ export class Page extends EventEmitter {
* Locators API is experimental and we will not follow semver for breaking * Locators API is experimental and we will not follow semver for breaking
* change in the Locators API. * change in the Locators API.
*/ */
locator(selector: string): Locator { locator<Selector extends string>(
selector: Selector
): Locator<NodeFor<Selector>> {
return Locator.create(this, selector); return Locator.create(this, selector);
} }
@ -840,7 +842,7 @@ export class Page extends EventEmitter {
* *
* @internal * @internal
*/ */
locatorRace(locators: Locator[]): Locator { locatorRace(locators: Array<Locator<Node>>): Locator<Node> {
return Locator.race(locators); return Locator.race(locators);
} }

View File

@ -2399,6 +2399,12 @@
"parameters": ["firefox", "webDriverBiDi"], "parameters": ["firefox", "webDriverBiDi"],
"expectations": ["SKIP"] "expectations": ["SKIP"]
}, },
{
"testIdPattern": "[locator.spec] Locator Locator.prototype.expect should resolve as soon as the predicate matches",
"platforms": ["darwin", "linux", "win32"],
"parameters": ["chrome", "webDriverBiDi"],
"expectations": ["TIMEOUT"]
},
{ {
"testIdPattern": "[mouse.spec] Mouse should reset properly", "testIdPattern": "[mouse.spec] Mouse should reset properly",
"platforms": ["darwin", "linux", "win32"], "platforms": ["darwin", "linux", "win32"],

View File

@ -538,4 +538,59 @@ describe('Locator', function () {
await expect(result).resolves.toEqual(undefined); await expect(result).resolves.toEqual(undefined);
}); });
}); });
describe('Locator.prototype.expect', () => {
it('should not resolve if the predicate does not match', async () => {
const clock = sinon.useFakeTimers({
shouldClearNativeTimers: true,
});
try {
const {page} = await getTestState();
page.setDefaultTimeout(5000);
await page.setContent(`<div>test</div>`);
const result = page
.locator('::-p-text(test)')
.expect((element): Promise<boolean> => {
return Promise.resolve(
element.getAttribute('clickable') === 'true'
);
})
.hover();
clock.tick(5100);
await expect(result).rejects.toEqual(
new TimeoutError('waitForFunction timed out. The timeout is 5000ms.')
);
} finally {
clock.restore();
}
});
it('should resolve as soon as the predicate matches', async () => {
const clock = sinon.useFakeTimers({
shouldClearNativeTimers: true,
});
try {
const {page} = await getTestState();
page.setDefaultTimeout(5000);
await page.setContent(`<div>test</div>`);
const result = page
.locator('::-p-text(test)')
.expect(async element => {
return element.getAttribute('clickable') === 'true';
})
.expect(element => {
return element.getAttribute('clickable') === 'true';
})
.hover();
clock.tick(2000);
await page.evaluate(() => {
document.querySelector('div')?.setAttribute('clickable', 'true');
});
clock.tick(2000);
await expect(result).resolves.toEqual(undefined);
} finally {
clock.restore();
}
});
});
}); });