diff --git a/docs/api/index.md b/docs/api/index.md index a849c4e6fdc..b391efc2670 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -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 puppeteer.launch without having to list the set of all types. | | [ResourceType](./puppeteer.resourcetype.md) | Resource types for HTTPRequests as perceived by the rendering engine. | | [TargetFilterCallback](./puppeteer.targetfiltercallback.md) | | +| [UnionLocatorOf](./puppeteer.unionlocatorof.md) | | | [VisibilityOption](./puppeteer.visibilityoption.md) | | diff --git a/docs/api/puppeteer.locator.race.md b/docs/api/puppeteer.locator.race.md index b6055a8adbb..642df259b9c 100644 --- a/docs/api/puppeteer.locator.race.md +++ b/docs/api/puppeteer.locator.race.md @@ -24,4 +24,4 @@ class Locator { **Returns:** -[Locator](./puppeteer.locator.md)<UnionLocatorOf<Locators>> +[Locator](./puppeteer.locator.md)<[UnionLocatorOf](./puppeteer.unionlocatorof.md)<Locators>> diff --git a/docs/api/puppeteer.unionlocatorof.md b/docs/api/puppeteer.unionlocatorof.md new file mode 100644 index 00000000000..ea442953b4d --- /dev/null +++ b/docs/api/puppeteer.unionlocatorof.md @@ -0,0 +1,13 @@ +--- +sidebar_label: UnionLocatorOf +--- + +# UnionLocatorOf type + +#### Signature: + +```typescript +export type UnionLocatorOf = T extends Array> ? S : never; +``` + +**References:** [Locator](./puppeteer.locator.md) diff --git a/packages/puppeteer-core/src/api/Frame.ts b/packages/puppeteer-core/src/api/Frame.ts index 9ff895f40e1..c5e56872221 100644 --- a/packages/puppeteer-core/src/api/Frame.ts +++ b/packages/puppeteer-core/src/api/Frame.ts @@ -39,7 +39,7 @@ import {TaskManager} from '../common/WaitTask.js'; import {KeyboardTypeOptions} from './Input.js'; import {JSHandle} from './JSHandle.js'; -import {Locator} from './Locator.js'; +import {Locator, NodeLocator} from './locators/locators.js'; /** * @internal @@ -427,7 +427,7 @@ export class Frame { locator( selector: Selector ): Locator> { - return Locator.create(this, selector); + return NodeLocator.create(this, selector); } /** diff --git a/packages/puppeteer-core/src/api/Page.ts b/packages/puppeteer-core/src/api/Page.ts index f073a275ae8..386ab123d43 100644 --- a/packages/puppeteer-core/src/api/Page.ts +++ b/packages/puppeteer-core/src/api/Page.ts @@ -71,9 +71,9 @@ import type { FrameAddStyleTagOptions, FrameWaitForFunctionOptions, } 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 {Locator} from './Locator.js'; +import {Locator, NodeLocator} from './locators/locators.js'; /** * @public @@ -834,7 +834,7 @@ export class Page extends EventEmitter { locator( selector: Selector ): Locator> { - return Locator.create(this, selector); + return NodeLocator.create(this, selector); } /** diff --git a/packages/puppeteer-core/src/api/api.ts b/packages/puppeteer-core/src/api/api.ts index f4bcd2ef4fe..5ad76682c1c 100644 --- a/packages/puppeteer-core/src/api/api.ts +++ b/packages/puppeteer-core/src/api/api.ts @@ -23,4 +23,4 @@ export * from './Input.js'; export * from './Frame.js'; export * from './HTTPResponse.js'; export * from './HTTPRequest.js'; -export * from './Locator.js'; +export * from './locators/locators.js'; diff --git a/packages/puppeteer-core/src/api/locators/ExpectedLocator.ts b/packages/puppeteer-core/src/api/locators/ExpectedLocator.ts new file mode 100644 index 00000000000..b11571a0482 --- /dev/null +++ b/packages/puppeteer-core/src/api/locators/ExpectedLocator.ts @@ -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 = + | ((value: From) => value is To) + | ((value: From) => Awaitable); + +/** + * @internal + */ +export class ExpectedLocator extends Locator { + #base: Locator; + #predicate: Predicate; + + constructor(base: Locator, predicate: Predicate) { + 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 = async (handle, signal) => { + // TODO(jrandolf): We should remove this once JSHandle has waitForFunction. + await (handle as ElementHandle).frame.waitForFunction( + this.#predicate, + {signal}, + handle + ); + }; + + #insertFilterCondition< + FromElement extends Node, + ToElement extends FromElement, + >(this: ExpectedLocator): void { + const context = (LOCATOR_CONTEXTS.get(this.#base) ?? + {}) as LocatorContext; + context.conditions ??= new Set(); + context.conditions.add(this.#condition); + LOCATOR_CONTEXTS.set(this.#base, context); + } + + override click( + this: ExpectedLocator, + options?: Readonly + ): Promise { + this.#insertFilterCondition(); + return this.#base.click(options); + } + override fill( + this: ExpectedLocator, + value: string, + options?: Readonly + ): Promise { + this.#insertFilterCondition(); + return this.#base.fill(value, options); + } + override hover( + this: ExpectedLocator, + options?: Readonly + ): Promise { + this.#insertFilterCondition(); + return this.#base.hover(options); + } + override scroll( + this: ExpectedLocator, + options?: Readonly + ): Promise { + this.#insertFilterCondition(); + return this.#base.scroll(options); + } +} diff --git a/packages/puppeteer-core/src/api/locators/Locator.ts b/packages/puppeteer-core/src/api/locators/Locator.ts new file mode 100644 index 00000000000..3d8e99f0c61 --- /dev/null +++ b/packages/puppeteer-core/src/api/locators/Locator.ts @@ -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 { + conditions?: Set>; +} + +/** + * @internal + */ +export const LOCATOR_CONTEXTS = new WeakMap< + Locator, + LocatorContext +>(); + +/** + * @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 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: Locators + ): Locator> { + return new RaceLocator( + locators as Array>> + ); + } + + /** + * Creates an expectation that is evaluated against located values. + * + * If the expectations do not match, then the locator will retry. + * + * @internal + */ + expect(predicate: Predicate): Locator { + return new ExpectedLocator(this, predicate); + } + + override on( + eventName: K, + handler: (event: LocatorEventObject[K]) => void + ): this { + return super.on(eventName, handler); + } + + override once( + eventName: K, + handler: (event: LocatorEventObject[K]) => void + ): this { + return super.once(eventName, handler); + } + + override off( + 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( + this: Locator, + options?: Readonly + ): Promise; + + /** + * 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( + this: Locator, + value: string, + options?: Readonly + ): Promise; + + abstract hover( + this: Locator, + options?: Readonly + ): Promise; + + abstract scroll( + this: Locator, + options?: Readonly + ): Promise; +} diff --git a/packages/puppeteer-core/src/api/Locator.ts b/packages/puppeteer-core/src/api/locators/NodeLocator.ts similarity index 52% rename from packages/puppeteer-core/src/api/Locator.ts rename to packages/puppeteer-core/src/api/locators/NodeLocator.ts index 5ad079bf6cd..7e87c938420 100644 --- a/packages/puppeteer-core/src/api/Locator.ts +++ b/packages/puppeteer-core/src/api/locators/NodeLocator.ts @@ -1,75 +1,20 @@ -/** - * Copyright 2023 Google Inc. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * 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 {HandleFor, NodeFor} from '../../common/types.js'; +import {debugError} from '../../common/util.js'; +import {isErrorLike} from '../../util/ErrorLike.js'; +import {BoundingBox, ElementHandle} from '../ElementHandle.js'; +import type {Frame} from '../Frame.js'; +import type {Page} from '../Page.js'; -import {TimeoutError} from '../common/Errors.js'; -import {EventEmitter} from '../common/EventEmitter.js'; -import {Awaitable, HandleFor, NodeFor} from '../common/types.js'; -import {debugError} from '../common/util.js'; -import {isErrorLike} from '../util/ErrorLike.js'; - -import {BoundingBox, ClickOptions, ElementHandle} from './ElementHandle.js'; -import type {Frame} from './Frame.js'; -import type {Page} from './Page.js'; - -interface LocatorContext { - conditions?: Set>; -} - -const LOCATOR_CONTEXTS = new WeakMap, LocatorContext>(); - -/** - * @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; -} +import { + ActionOptions, + LOCATOR_CONTEXTS, + Locator, + LocatorClickOptions, + LocatorEmittedEvents, + LocatorScrollOptions, + VisibilityOption, +} from './locators.js'; /** * 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 * element means multiple asynchronious operations. */ -const CONDITION_TIMEOUT = 1_000; +const CONDITION_TIMEOUT = 1000; const WAIT_FOR_FUNCTION_DELAY = 100; /** @@ -89,67 +34,9 @@ export type ActionCondition = ( ) => Promise; /** - * @public + * @internal */ -export type Predicate = - | ((value: From) => value is To) - | ((value: From) => Awaitable); - -/** - * @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 extends Array> ? 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 extends EventEmitter { - /** - * Used for nominally typing {@link Locator}. - */ - declare _?: T; - +export class NodeLocator extends Locator { /** * @internal */ @@ -164,96 +51,10 @@ export abstract class Locator extends EventEmitter { ); } - /** - * Creates a race between multiple locators but ensures that only a single one - * acts. - */ - static race>>( - locators: Locators - ): Locator> { - return new RaceLocator( - locators as Array>> - ); - } - - /** - * Creates an expectation that is evaluated against located values. - * - * If the expectations do not match, then the locator will retry. - * - * @internal - */ - expect(predicate: Predicate): Locator { - return new ExpectedLocator(this, predicate); - } - - override on( - eventName: K, - handler: (event: LocatorEventObject[K]) => void - ): this { - return super.on(eventName, handler); - } - - override once( - eventName: K, - handler: (event: LocatorEventObject[K]) => void - ): this { - return super.once(eventName, handler); - } - - override off( - 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( - this: Locator, - options?: Readonly - ): Promise; - - /** - * 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( - this: Locator, - value: string, - options?: Readonly - ): Promise; - - abstract hover( - this: Locator, - options?: Readonly - ): Promise; - - abstract scroll( - this: Locator, - options?: Readonly - ): Promise; -} - -/** - * @internal - */ -export class NodeLocator extends Locator { #pageOrFrame: Page | Frame; #selector: string; #visibility: VisibilityOption = 'visible'; - #timeout = 30_000; + #timeout = 30000; #ensureElementIsInTheViewport = true; #waitForEnabled = true; #waitForStableBoundingBox = true; @@ -694,245 +495,3 @@ export class NodeLocator extends Locator { ); } } - -class ExpectedLocator extends Locator { - #base: Locator; - #predicate: Predicate; - - constructor(base: Locator, predicate: Predicate) { - 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 = async (handle, signal) => { - // TODO(jrandolf): We should remove this once JSHandle has waitForFunction. - await (handle as ElementHandle).frame.waitForFunction( - this.#predicate, - {signal}, - handle - ); - }; - - #insertFilterCondition< - FromElement extends Node, - ToElement extends FromElement, - >(this: ExpectedLocator): void { - const context = (LOCATOR_CONTEXTS.get(this.#base) ?? - {}) as LocatorContext; - context.conditions ??= new Set(); - context.conditions.add(this.#condition); - LOCATOR_CONTEXTS.set(this.#base, context); - } - - override click( - this: ExpectedLocator, - options?: Readonly - ): Promise { - this.#insertFilterCondition(); - return this.#base.click(options); - } - override fill( - this: ExpectedLocator, - value: string, - options?: Readonly - ): Promise { - this.#insertFilterCondition(); - return this.#base.fill(value, options); - } - override hover( - this: ExpectedLocator, - options?: Readonly - ): Promise { - this.#insertFilterCondition(); - return this.#base.hover(options); - } - override scroll( - this: ExpectedLocator, - options?: Readonly - ): Promise { - this.#insertFilterCondition(); - return this.#base.scroll(options); - } -} - -/** - * @internal - */ -class RaceLocator extends Locator { - #locators: Array>; - - constructor(locators: Array>) { - 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, signal: AbortSignal) => Promise, - signal?: AbortSignal - ) { - const abortControllers = new WeakMap, 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): (() => 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): 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( - this: RaceLocator, - options?: Readonly - ): Promise { - return await this.#run( - (locator, signal) => { - return locator.click({...options, signal}); - }, - options?.signal - ); - } - - async fill( - this: RaceLocator, - value: string, - options?: Readonly - ): Promise { - return await this.#run( - (locator, signal) => { - return locator.fill(value, {...options, signal}); - }, - options?.signal - ); - } - - async hover( - this: RaceLocator, - options?: Readonly - ): Promise { - return await this.#run( - (locator, signal) => { - return locator.hover({...options, signal}); - }, - options?.signal - ); - } - - async scroll( - this: RaceLocator, - options?: Readonly - ): Promise { - return await this.#run( - (locator, signal) => { - return locator.scroll({...options, signal}); - }, - options?.signal - ); - } -} diff --git a/packages/puppeteer-core/src/api/locators/RaceLocator.ts b/packages/puppeteer-core/src/api/locators/RaceLocator.ts new file mode 100644 index 00000000000..6fd5aeca51c --- /dev/null +++ b/packages/puppeteer-core/src/api/locators/RaceLocator.ts @@ -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 extends Array> ? S : never; + +/** + * @internal + */ +export class RaceLocator extends Locator { + #locators: Array>; + + constructor(locators: Array>) { + 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, signal: AbortSignal) => Promise, + signal?: AbortSignal + ) { + const abortControllers = new WeakMap, 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): (() => 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): 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( + this: RaceLocator, + options?: Readonly + ): Promise { + return await this.#run( + (locator, signal) => { + return locator.click({...options, signal}); + }, + options?.signal + ); + } + + async fill( + this: RaceLocator, + value: string, + options?: Readonly + ): Promise { + return await this.#run( + (locator, signal) => { + return locator.fill(value, {...options, signal}); + }, + options?.signal + ); + } + + async hover( + this: RaceLocator, + options?: Readonly + ): Promise { + return await this.#run( + (locator, signal) => { + return locator.hover({...options, signal}); + }, + options?.signal + ); + } + + async scroll( + this: RaceLocator, + options?: Readonly + ): Promise { + return await this.#run( + (locator, signal) => { + return locator.scroll({...options, signal}); + }, + options?.signal + ); + } +} diff --git a/packages/puppeteer-core/src/api/locators/locators.ts b/packages/puppeteer-core/src/api/locators/locators.ts new file mode 100644 index 00000000000..fe44cc4cd68 --- /dev/null +++ b/packages/puppeteer-core/src/api/locators/locators.ts @@ -0,0 +1,4 @@ +export * from './Locator.js'; +export * from './NodeLocator.js'; +export * from './ExpectedLocator.js'; +export * from './RaceLocator.js'; diff --git a/test/src/locator.spec.ts b/test/src/locator.spec.ts index a938128d130..89bb437a0f2 100644 --- a/test/src/locator.spec.ts +++ b/test/src/locator.spec.ts @@ -19,7 +19,7 @@ import {TimeoutError} from 'puppeteer-core'; import { Locator, LocatorEmittedEvents, -} from 'puppeteer-core/internal/api/Locator.js'; +} from 'puppeteer-core/internal/api/locators/locators.js'; import sinon from 'sinon'; import {getTestState, setupTestBrowserHooks} from './mocha-utils.js';