refactor: move locators to separate files (#10591)

This commit is contained in:
jrandolf 2023-07-20 15:06:42 +02:00 committed by GitHub
parent 4a3b8b2d9e
commit 0715ad8281
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 527 additions and 469 deletions

View File

@ -181,4 +181,5 @@ sidebar_label: API
| [PuppeteerNodeLaunchOptions](./puppeteer.puppeteernodelaunchoptions.md) | Utility type exposed to enable users to define options that can be passed to <code>puppeteer.launch</code> without having to list the set of all types. | | [PuppeteerNodeLaunchOptions](./puppeteer.puppeteernodelaunchoptions.md) | Utility type exposed to enable users to define options that can be passed to <code>puppeteer.launch</code> without having to list the set of all types. |
| [ResourceType](./puppeteer.resourcetype.md) | Resource types for HTTPRequests as perceived by the rendering engine. | | [ResourceType](./puppeteer.resourcetype.md) | Resource types for HTTPRequests as perceived by the rendering engine. |
| [TargetFilterCallback](./puppeteer.targetfiltercallback.md) | | | [TargetFilterCallback](./puppeteer.targetfiltercallback.md) | |
| [UnionLocatorOf](./puppeteer.unionlocatorof.md) | |
| [VisibilityOption](./puppeteer.visibilityoption.md) | | | [VisibilityOption](./puppeteer.visibilityoption.md) | |

View File

@ -24,4 +24,4 @@ class Locator {
**Returns:** **Returns:**
[Locator](./puppeteer.locator.md)&lt;UnionLocatorOf&lt;Locators&gt;&gt; [Locator](./puppeteer.locator.md)&lt;[UnionLocatorOf](./puppeteer.unionlocatorof.md)&lt;Locators&gt;&gt;

View File

@ -0,0 +1,13 @@
---
sidebar_label: UnionLocatorOf
---
# UnionLocatorOf type
#### Signature:
```typescript
export type UnionLocatorOf<T> = T extends Array<Locator<infer S>> ? S : never;
```
**References:** [Locator](./puppeteer.locator.md)

View File

@ -39,7 +39,7 @@ import {TaskManager} from '../common/WaitTask.js';
import {KeyboardTypeOptions} from './Input.js'; import {KeyboardTypeOptions} from './Input.js';
import {JSHandle} from './JSHandle.js'; import {JSHandle} from './JSHandle.js';
import {Locator} from './Locator.js'; import {Locator, NodeLocator} from './locators/locators.js';
/** /**
* @internal * @internal
@ -427,7 +427,7 @@ export class Frame {
locator<Selector extends string>( locator<Selector extends string>(
selector: Selector selector: Selector
): Locator<NodeFor<Selector>> { ): Locator<NodeFor<Selector>> {
return Locator.create(this, selector); return NodeLocator.create(this, selector);
} }
/** /**

View File

@ -71,9 +71,9 @@ import type {
FrameAddStyleTagOptions, FrameAddStyleTagOptions,
FrameWaitForFunctionOptions, FrameWaitForFunctionOptions,
} from './Frame.js'; } from './Frame.js';
import {Keyboard, Mouse, Touchscreen, KeyboardTypeOptions} from './Input.js'; import {Keyboard, KeyboardTypeOptions, Mouse, Touchscreen} from './Input.js';
import type {JSHandle} from './JSHandle.js'; import type {JSHandle} from './JSHandle.js';
import {Locator} from './Locator.js'; import {Locator, NodeLocator} from './locators/locators.js';
/** /**
* @public * @public
@ -834,7 +834,7 @@ export class Page extends EventEmitter {
locator<Selector extends string>( locator<Selector extends string>(
selector: Selector selector: Selector
): Locator<NodeFor<Selector>> { ): Locator<NodeFor<Selector>> {
return Locator.create(this, selector); return NodeLocator.create(this, selector);
} }
/** /**

View File

@ -23,4 +23,4 @@ export * from './Input.js';
export * from './Frame.js'; export * from './Frame.js';
export * from './HTTPResponse.js'; export * from './HTTPResponse.js';
export * from './HTTPRequest.js'; export * from './HTTPRequest.js';
export * from './Locator.js'; export * from './locators/locators.js';

View File

@ -0,0 +1,106 @@
import {Awaitable} from '../../common/common.js';
import {ElementHandle} from '../ElementHandle.js';
import {
ActionOptions,
LOCATOR_CONTEXTS,
Locator,
LocatorClickOptions,
LocatorContext,
LocatorScrollOptions,
VisibilityOption,
type ActionCondition,
} from './locators.js';
/**
* @public
*/
export type Predicate<From, To extends From = From> =
| ((value: From) => value is To)
| ((value: From) => Awaitable<boolean>);
/**
* @internal
*/
export 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);
}
}

View File

@ -0,0 +1,201 @@
import {EventEmitter} from '../../common/EventEmitter.js';
import {ClickOptions} from '../ElementHandle.js';
import {
ExpectedLocator,
Predicate,
RaceLocator,
UnionLocatorOf,
type ActionCondition,
} from './locators.js';
/**
* @internal
*/
export interface LocatorContext<T> {
conditions?: Set<ActionCondition<T>>;
}
/**
* @internal
*/
export const LOCATOR_CONTEXTS = new WeakMap<
Locator<unknown>,
LocatorContext<never>
>();
/**
* @public
*/
export type VisibilityOption = 'hidden' | 'visible' | null;
/**
* @public
*/
export interface LocatorOptions {
/**
* Whether to wait for the element to be `visible` or `hidden`. `null` to
* disable visibility checks.
*/
visibility: VisibilityOption;
/**
* Total timeout for the entire locator operation.
*
* Pass `0` to disable timeout.
*
* @defaultValue `Page.getDefaultTimeout()`
*/
timeout: number;
/**
* Whether to scroll the element into viewport if not in the viewprot already.
* @defaultValue `true`
*/
ensureElementIsInTheViewport: boolean;
/**
* Whether to wait for input elements to become enabled before the action.
* Applicable to `click` and `fill` actions.
* @defaultValue `true`
*/
waitForEnabled: boolean;
/**
* Whether to wait for the element's bounding box to be same between two
* animation frames.
* @defaultValue `true`
*/
waitForStableBoundingBox: boolean;
}
/**
* @public
*/
export interface ActionOptions {
signal?: AbortSignal;
}
/**
* @public
*/
export type LocatorClickOptions = ClickOptions & ActionOptions;
/**
* @public
*/
export interface LocatorScrollOptions extends ActionOptions {
scrollTop?: number;
scrollLeft?: number;
}
/**
* All the events that a locator instance may emit.
*
* @public
*/
export enum LocatorEmittedEvents {
/**
* Emitted every time before the locator performs an action on the located element(s).
*/
Action = 'action',
}
/**
* @public
*/
export interface LocatorEventObject {
[LocatorEmittedEvents.Action]: never;
}
/**
* 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,
* the whole operation is retried. Various preconditions for a successful action
* are checked automatically.
*
* @public
*/
export abstract class Locator<T> extends EventEmitter {
/**
* Used for nominally typing {@link Locator}.
*/
declare _?: T;
/**
* Creates a race between multiple locators but ensures that only a single one
* acts.
*/
static race<Locators extends Array<Locator<unknown>>>(
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>(
eventName: K,
handler: (event: LocatorEventObject[K]) => void
): this {
return super.on(eventName, handler);
}
override once<K extends keyof LocatorEventObject>(
eventName: K,
handler: (event: LocatorEventObject[K]) => void
): this {
return super.once(eventName, handler);
}
override off<K extends keyof LocatorEventObject>(
eventName: K,
handler: (event: LocatorEventObject[K]) => void
): this {
return super.off(eventName, handler);
}
abstract setVisibility(visibility: VisibilityOption): this;
abstract setTimeout(timeout: number): this;
abstract setEnsureElementIsInTheViewport(value: boolean): this;
abstract setWaitForEnabled(value: boolean): this;
abstract setWaitForStableBoundingBox(value: boolean): this;
abstract click<ElementType extends Element>(
this: Locator<ElementType>,
options?: Readonly<LocatorClickOptions>
): Promise<void>;
/**
* 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.
*/
abstract fill<ElementType extends Element>(
this: Locator<ElementType>,
value: string,
options?: Readonly<ActionOptions>
): Promise<void>;
abstract hover<ElementType extends Element>(
this: Locator<ElementType>,
options?: Readonly<ActionOptions>
): Promise<void>;
abstract scroll<ElementType extends Element>(
this: Locator<ElementType>,
options?: Readonly<LocatorScrollOptions>
): Promise<void>;
}

View File

@ -1,75 +1,20 @@
/** import {TimeoutError} from '../../common/Errors.js';
* Copyright 2023 Google Inc. All rights reserved. import {HandleFor, NodeFor} from '../../common/types.js';
* import {debugError} from '../../common/util.js';
* Licensed under the Apache License, Version 2.0 (the "License"); import {isErrorLike} from '../../util/ErrorLike.js';
* you may not use this file except in compliance with the License. import {BoundingBox, ElementHandle} from '../ElementHandle.js';
* You may obtain a copy of the License at import type {Frame} from '../Frame.js';
* import type {Page} from '../Page.js';
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {TimeoutError} from '../common/Errors.js'; import {
import {EventEmitter} from '../common/EventEmitter.js'; ActionOptions,
import {Awaitable, HandleFor, NodeFor} from '../common/types.js'; LOCATOR_CONTEXTS,
import {debugError} from '../common/util.js'; Locator,
import {isErrorLike} from '../util/ErrorLike.js'; LocatorClickOptions,
LocatorEmittedEvents,
import {BoundingBox, ClickOptions, ElementHandle} from './ElementHandle.js'; LocatorScrollOptions,
import type {Frame} from './Frame.js'; VisibilityOption,
import type {Page} from './Page.js'; } from './locators.js';
interface LocatorContext<T> {
conditions?: Set<ActionCondition<T>>;
}
const LOCATOR_CONTEXTS = new WeakMap<Locator<unknown>, LocatorContext<never>>();
/**
* @public
*/
export type VisibilityOption = 'hidden' | 'visible' | null;
/**
* @public
*/
export interface LocatorOptions {
/**
* Whether to wait for the element to be `visible` or `hidden`. `null` to
* disable visibility checks.
*/
visibility: VisibilityOption;
/**
* Total timeout for the entire locator operation.
*
* Pass `0` to disable timeout.
*
* @defaultValue `Page.getDefaultTimeout()`
*/
timeout: number;
/**
* Whether to scroll the element into viewport if not in the viewprot already.
* @defaultValue `true`
*/
ensureElementIsInTheViewport: boolean;
/**
* Whether to wait for input elements to become enabled before the action.
* Applicable to `click` and `fill` actions.
* @defaultValue `true`
*/
waitForEnabled: boolean;
/**
* Whether to wait for the element's bounding box to be same between two
* animation frames.
* @defaultValue `true`
*/
waitForStableBoundingBox: boolean;
}
/** /**
* Timeout for individual operations inside the locator. On errors the * Timeout for individual operations inside the locator. On errors the
@ -77,7 +22,7 @@ export interface LocatorOptions {
* exceeded. This timeout should be generally much lower as locating an * exceeded. This timeout should be generally much lower as locating an
* element means multiple asynchronious operations. * element means multiple asynchronious operations.
*/ */
const CONDITION_TIMEOUT = 1_000; const CONDITION_TIMEOUT = 1000;
const WAIT_FOR_FUNCTION_DELAY = 100; const WAIT_FOR_FUNCTION_DELAY = 100;
/** /**
@ -89,67 +34,9 @@ export type ActionCondition<T> = (
) => Promise<void>; ) => Promise<void>;
/** /**
* @public * @internal
*/ */
export type Predicate<From, To extends From = From> = export class NodeLocator<T extends Node> extends Locator<T> {
| ((value: From) => value is To)
| ((value: From) => Awaitable<boolean>);
/**
* @public
*/
export interface ActionOptions {
signal?: AbortSignal;
}
/**
* @public
*/
export type LocatorClickOptions = ClickOptions & ActionOptions;
/**
* @public
*/
export interface LocatorScrollOptions extends ActionOptions {
scrollTop?: number;
scrollLeft?: number;
}
/**
* All the events that a locator instance may emit.
*
* @public
*/
export enum LocatorEmittedEvents {
/**
* Emitted every time before the locator performs an action on the located element(s).
*/
Action = 'action',
}
/**
* @public
*/
export interface LocatorEventObject {
[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
* them. If the action fails because the element is not ready for the action,
* the whole operation is retried. Various preconditions for a successful action
* are checked automatically.
*
* @public
*/
export abstract class Locator<T> extends EventEmitter {
/**
* Used for nominally typing {@link Locator}.
*/
declare _?: T;
/** /**
* @internal * @internal
*/ */
@ -164,96 +51,10 @@ export abstract class Locator<T> extends EventEmitter {
); );
} }
/**
* Creates a race between multiple locators but ensures that only a single one
* acts.
*/
static race<Locators extends Array<Locator<unknown>>>(
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>(
eventName: K,
handler: (event: LocatorEventObject[K]) => void
): this {
return super.on(eventName, handler);
}
override once<K extends keyof LocatorEventObject>(
eventName: K,
handler: (event: LocatorEventObject[K]) => void
): this {
return super.once(eventName, handler);
}
override off<K extends keyof LocatorEventObject>(
eventName: K,
handler: (event: LocatorEventObject[K]) => void
): this {
return super.off(eventName, handler);
}
abstract setVisibility(visibility: VisibilityOption): this;
abstract setTimeout(timeout: number): this;
abstract setEnsureElementIsInTheViewport(value: boolean): this;
abstract setWaitForEnabled(value: boolean): this;
abstract setWaitForStableBoundingBox(value: boolean): this;
abstract click<ElementType extends Element>(
this: Locator<ElementType>,
options?: Readonly<LocatorClickOptions>
): Promise<void>;
/**
* 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.
*/
abstract fill<ElementType extends Element>(
this: Locator<ElementType>,
value: string,
options?: Readonly<ActionOptions>
): Promise<void>;
abstract hover<ElementType extends Element>(
this: Locator<ElementType>,
options?: Readonly<ActionOptions>
): Promise<void>;
abstract scroll<ElementType extends Element>(
this: Locator<ElementType>,
options?: Readonly<LocatorScrollOptions>
): Promise<void>;
}
/**
* @internal
*/
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';
#timeout = 30_000; #timeout = 30000;
#ensureElementIsInTheViewport = true; #ensureElementIsInTheViewport = true;
#waitForEnabled = true; #waitForEnabled = true;
#waitForStableBoundingBox = true; #waitForStableBoundingBox = true;
@ -694,245 +495,3 @@ export class NodeLocator<T extends Node> extends Locator<T> {
); );
} }
} }
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
*/
class RaceLocator<T> extends Locator<T> {
#locators: Array<Locator<T>>;
constructor(locators: Array<Locator<T>>) {
super();
this.#locators = locators;
}
override setVisibility(visibility: VisibilityOption): this {
for (const locator of this.#locators) {
locator.setVisibility(visibility);
}
return this;
}
override setTimeout(timeout: number): this {
for (const locator of this.#locators) {
locator.setTimeout(timeout);
}
return this;
}
override setEnsureElementIsInTheViewport(value: boolean): this {
for (const locator of this.#locators) {
locator.setEnsureElementIsInTheViewport(value);
}
return this;
}
override setWaitForEnabled(value: boolean): this {
for (const locator of this.#locators) {
locator.setWaitForEnabled(value);
}
return this;
}
override setWaitForStableBoundingBox(value: boolean): this {
for (const locator of this.#locators) {
locator.setWaitForStableBoundingBox(value);
}
return this;
}
async #run(
action: (locator: Locator<T>, signal: AbortSignal) => Promise<void>,
signal?: AbortSignal
) {
const abortControllers = new WeakMap<Locator<T>, AbortController>();
// Abort all locators if the user-provided signal aborts.
signal?.addEventListener('abort', () => {
for (const locator of this.#locators) {
abortControllers.get(locator)?.abort();
}
});
const handleLocatorAction = (locator: Locator<T>): (() => void) => {
return () => {
// When one locator is ready to act, we will abort other locators.
for (const other of this.#locators) {
if (other !== locator) {
abortControllers.get(other)?.abort();
}
}
this.emit(LocatorEmittedEvents.Action);
};
};
const createAbortController = (locator: Locator<T>): AbortController => {
const abortController = new AbortController();
abortControllers.set(locator, abortController);
return abortController;
};
const results = await Promise.allSettled(
this.#locators.map(locator => {
return action(
locator.on(LocatorEmittedEvents.Action, handleLocatorAction(locator)),
createAbortController(locator).signal
);
})
);
signal?.throwIfAborted();
const rejected = results.filter(
(result): result is PromiseRejectedResult => {
return result.status === 'rejected';
}
);
// If some locators are fulfilled, do not throw.
if (rejected.length !== results.length) {
return;
}
for (const result of rejected) {
const reason = result.reason;
// AbortError is be an expected result of a race.
if (isErrorLike(reason) && reason.name === 'AbortError') {
continue;
}
throw reason;
}
}
async click<ElementType extends Element>(
this: RaceLocator<ElementType>,
options?: Readonly<LocatorClickOptions>
): Promise<void> {
return await this.#run(
(locator, signal) => {
return locator.click({...options, signal});
},
options?.signal
);
}
async fill<ElementType extends Element>(
this: RaceLocator<ElementType>,
value: string,
options?: Readonly<ActionOptions>
): Promise<void> {
return await this.#run(
(locator, signal) => {
return locator.fill(value, {...options, signal});
},
options?.signal
);
}
async hover<ElementType extends Element>(
this: RaceLocator<ElementType>,
options?: Readonly<ActionOptions>
): Promise<void> {
return await this.#run(
(locator, signal) => {
return locator.hover({...options, signal});
},
options?.signal
);
}
async scroll<ElementType extends Element>(
this: RaceLocator<ElementType>,
options?: Readonly<LocatorScrollOptions>
): Promise<void> {
return await this.#run(
(locator, signal) => {
return locator.scroll({...options, signal});
},
options?.signal
);
}
}

View File

@ -0,0 +1,174 @@
import {isErrorLike} from '../../util/ErrorLike.js';
import {
Locator,
VisibilityOption,
LocatorEmittedEvents,
LocatorClickOptions,
ActionOptions,
LocatorScrollOptions,
} from './locators.js';
/**
* @public
*/
export type UnionLocatorOf<T> = T extends Array<Locator<infer S>> ? S : never;
/**
* @internal
*/
export class RaceLocator<T> extends Locator<T> {
#locators: Array<Locator<T>>;
constructor(locators: Array<Locator<T>>) {
super();
this.#locators = locators;
}
override setVisibility(visibility: VisibilityOption): this {
for (const locator of this.#locators) {
locator.setVisibility(visibility);
}
return this;
}
override setTimeout(timeout: number): this {
for (const locator of this.#locators) {
locator.setTimeout(timeout);
}
return this;
}
override setEnsureElementIsInTheViewport(value: boolean): this {
for (const locator of this.#locators) {
locator.setEnsureElementIsInTheViewport(value);
}
return this;
}
override setWaitForEnabled(value: boolean): this {
for (const locator of this.#locators) {
locator.setWaitForEnabled(value);
}
return this;
}
override setWaitForStableBoundingBox(value: boolean): this {
for (const locator of this.#locators) {
locator.setWaitForStableBoundingBox(value);
}
return this;
}
async #run(
action: (locator: Locator<T>, signal: AbortSignal) => Promise<void>,
signal?: AbortSignal
) {
const abortControllers = new WeakMap<Locator<T>, AbortController>();
// Abort all locators if the user-provided signal aborts.
signal?.addEventListener('abort', () => {
for (const locator of this.#locators) {
abortControllers.get(locator)?.abort();
}
});
const handleLocatorAction = (locator: Locator<T>): (() => void) => {
return () => {
// When one locator is ready to act, we will abort other locators.
for (const other of this.#locators) {
if (other !== locator) {
abortControllers.get(other)?.abort();
}
}
this.emit(LocatorEmittedEvents.Action);
};
};
const createAbortController = (locator: Locator<T>): AbortController => {
const abortController = new AbortController();
abortControllers.set(locator, abortController);
return abortController;
};
const results = await Promise.allSettled(
this.#locators.map(locator => {
return action(
locator.on(LocatorEmittedEvents.Action, handleLocatorAction(locator)),
createAbortController(locator).signal
);
})
);
signal?.throwIfAborted();
const rejected = results.filter(
(result): result is PromiseRejectedResult => {
return result.status === 'rejected';
}
);
// If some locators are fulfilled, do not throw.
if (rejected.length !== results.length) {
return;
}
for (const result of rejected) {
const reason = result.reason;
// AbortError is be an expected result of a race.
if (isErrorLike(reason) && reason.name === 'AbortError') {
continue;
}
throw reason;
}
}
async click<ElementType extends Element>(
this: RaceLocator<ElementType>,
options?: Readonly<LocatorClickOptions>
): Promise<void> {
return await this.#run(
(locator, signal) => {
return locator.click({...options, signal});
},
options?.signal
);
}
async fill<ElementType extends Element>(
this: RaceLocator<ElementType>,
value: string,
options?: Readonly<ActionOptions>
): Promise<void> {
return await this.#run(
(locator, signal) => {
return locator.fill(value, {...options, signal});
},
options?.signal
);
}
async hover<ElementType extends Element>(
this: RaceLocator<ElementType>,
options?: Readonly<ActionOptions>
): Promise<void> {
return await this.#run(
(locator, signal) => {
return locator.hover({...options, signal});
},
options?.signal
);
}
async scroll<ElementType extends Element>(
this: RaceLocator<ElementType>,
options?: Readonly<LocatorScrollOptions>
): Promise<void> {
return await this.#run(
(locator, signal) => {
return locator.scroll({...options, signal});
},
options?.signal
);
}
}

View File

@ -0,0 +1,4 @@
export * from './Locator.js';
export * from './NodeLocator.js';
export * from './ExpectedLocator.js';
export * from './RaceLocator.js';

View File

@ -19,7 +19,7 @@ import {TimeoutError} from 'puppeteer-core';
import { import {
Locator, Locator,
LocatorEmittedEvents, LocatorEmittedEvents,
} from 'puppeteer-core/internal/api/Locator.js'; } from 'puppeteer-core/internal/api/locators/locators.js';
import sinon from 'sinon'; import sinon from 'sinon';
import {getTestState, setupTestBrowserHooks} from './mocha-utils.js'; import {getTestState, setupTestBrowserHooks} from './mocha-utils.js';