From 014c72ae1df4b4a58c65cbc50aa1955673daa7f2 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Mon, 23 Oct 2023 14:23:46 +0200 Subject: [PATCH] chore: merge locators in a single file (#11233) --- .eslintrc.js | 2 +- .../src/api/locators/DelegatedLocator.ts | 97 -- .../src/api/locators/FilteredLocator.ts | 83 -- .../src/api/locators/FunctionLocator.ts | 69 -- .../src/api/locators/Locator.ts | 770 ------------ .../src/api/locators/MappedLocator.ts | 67 - .../src/api/locators/NodeLocator.ts | 117 -- .../src/api/locators/RaceLocator.ts | 71 -- .../src/api/locators/locators.ts | 1089 ++++++++++++++++- 9 files changed, 1081 insertions(+), 1284 deletions(-) delete mode 100644 packages/puppeteer-core/src/api/locators/DelegatedLocator.ts delete mode 100644 packages/puppeteer-core/src/api/locators/FilteredLocator.ts delete mode 100644 packages/puppeteer-core/src/api/locators/FunctionLocator.ts delete mode 100644 packages/puppeteer-core/src/api/locators/Locator.ts delete mode 100644 packages/puppeteer-core/src/api/locators/MappedLocator.ts delete mode 100644 packages/puppeteer-core/src/api/locators/NodeLocator.ts delete mode 100644 packages/puppeteer-core/src/api/locators/RaceLocator.ts diff --git a/.eslintrc.js b/.eslintrc.js index 1459f430..90be582b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -132,7 +132,7 @@ module.exports = { ], 'import/no-cycle': [ - 'warn', + 'error', {maxDepth: Infinity, allowUnsafeDynamicCyclicDependency: true}, ], diff --git a/packages/puppeteer-core/src/api/locators/DelegatedLocator.ts b/packages/puppeteer-core/src/api/locators/DelegatedLocator.ts deleted file mode 100644 index 962eb2e6..00000000 --- a/packages/puppeteer-core/src/api/locators/DelegatedLocator.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * 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 type {Observable} from '../../../third_party/rxjs/rxjs.js'; -import type {HandleFor} from '../../common/types.js'; - -import {Locator, type VisibilityOption} from './locators.js'; - -/** - * @internal - */ -export abstract class DelegatedLocator extends Locator { - #delegate: Locator; - - constructor(delegate: Locator) { - super(); - - this.#delegate = delegate; - this.copyOptions(this.#delegate); - } - - protected get delegate(): Locator { - return this.#delegate; - } - - override setTimeout(timeout: number): DelegatedLocator { - const locator = super.setTimeout(timeout) as DelegatedLocator; - locator.#delegate = this.#delegate.setTimeout(timeout); - return locator; - } - - override setVisibility( - this: DelegatedLocator, - visibility: VisibilityOption - ): DelegatedLocator { - const locator = super.setVisibility( - visibility - ) as DelegatedLocator; - locator.#delegate = locator.#delegate.setVisibility(visibility); - return locator; - } - - override setWaitForEnabled( - this: DelegatedLocator, - value: boolean - ): DelegatedLocator { - const locator = super.setWaitForEnabled( - value - ) as DelegatedLocator; - locator.#delegate = this.#delegate.setWaitForEnabled(value); - return locator; - } - - override setEnsureElementIsInTheViewport< - ValueType extends Element, - ElementType extends Element, - >( - this: DelegatedLocator, - value: boolean - ): DelegatedLocator { - const locator = super.setEnsureElementIsInTheViewport( - value - ) as DelegatedLocator; - locator.#delegate = this.#delegate.setEnsureElementIsInTheViewport(value); - return locator; - } - - override setWaitForStableBoundingBox< - ValueType extends Element, - ElementType extends Element, - >( - this: DelegatedLocator, - value: boolean - ): DelegatedLocator { - const locator = super.setWaitForStableBoundingBox( - value - ) as DelegatedLocator; - locator.#delegate = this.#delegate.setWaitForStableBoundingBox(value); - return locator; - } - - abstract override _clone(): DelegatedLocator; - abstract override _wait(): Observable>; -} diff --git a/packages/puppeteer-core/src/api/locators/FilteredLocator.ts b/packages/puppeteer-core/src/api/locators/FilteredLocator.ts deleted file mode 100644 index 07032bba..00000000 --- a/packages/puppeteer-core/src/api/locators/FilteredLocator.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * 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 { - type Observable, - filter, - from, - map, - mergeMap, - throwIfEmpty, -} from '../../../third_party/rxjs/rxjs.js'; -import type {Awaitable, HandleFor} from '../../common/types.js'; - -import {DelegatedLocator} from './DelegatedLocator.js'; -import type {ActionOptions, Locator} from './locators.js'; - -/** - * @public - */ -export type Predicate = - | ((value: From) => value is To) - | ((value: From) => Awaitable); - -/** - * @internal - */ -export type HandlePredicate = - | ((value: HandleFor, signal?: AbortSignal) => value is HandleFor) - | ((value: HandleFor, signal?: AbortSignal) => Awaitable); - -/** - * @internal - */ -export class FilteredLocator extends DelegatedLocator< - From, - To -> { - #predicate: HandlePredicate; - - constructor(base: Locator, predicate: HandlePredicate) { - super(base); - this.#predicate = predicate; - } - - override _clone(): FilteredLocator { - return new FilteredLocator( - this.delegate.clone(), - this.#predicate - ).copyOptions(this); - } - - override _wait(options?: Readonly): Observable> { - return this.delegate._wait(options).pipe( - mergeMap(handle => { - return from( - Promise.resolve(this.#predicate(handle, options?.signal)) - ).pipe( - filter(value => { - return value; - }), - map(() => { - // SAFETY: It passed the predicate, so this is correct. - return handle as HandleFor; - }) - ); - }), - throwIfEmpty() - ); - } -} diff --git a/packages/puppeteer-core/src/api/locators/FunctionLocator.ts b/packages/puppeteer-core/src/api/locators/FunctionLocator.ts deleted file mode 100644 index 2f87b87a..00000000 --- a/packages/puppeteer-core/src/api/locators/FunctionLocator.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * 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 { - type Observable, - defer, - from, - throwIfEmpty, -} from '../../../third_party/rxjs/rxjs.js'; -import type {Awaitable, HandleFor} from '../../common/types.js'; -import type {Frame} from '../Frame.js'; -import type {Page} from '../Page.js'; - -import {type ActionOptions, Locator} from './locators.js'; - -/** - * @internal - */ -export class FunctionLocator extends Locator { - static create( - pageOrFrame: Page | Frame, - func: () => Awaitable - ): Locator { - return new FunctionLocator(pageOrFrame, func).setTimeout( - 'getDefaultTimeout' in pageOrFrame - ? pageOrFrame.getDefaultTimeout() - : pageOrFrame.page().getDefaultTimeout() - ); - } - - #pageOrFrame: Page | Frame; - #func: () => Awaitable; - - private constructor(pageOrFrame: Page | Frame, func: () => Awaitable) { - super(); - - this.#pageOrFrame = pageOrFrame; - this.#func = func; - } - - override _clone(): FunctionLocator { - return new FunctionLocator(this.#pageOrFrame, this.#func); - } - - _wait(options?: Readonly): Observable> { - const signal = options?.signal; - return defer(() => { - return from( - this.#pageOrFrame.waitForFunction(this.#func, { - timeout: this.timeout, - signal, - }) - ); - }).pipe(throwIfEmpty()); - } -} diff --git a/packages/puppeteer-core/src/api/locators/Locator.ts b/packages/puppeteer-core/src/api/locators/Locator.ts deleted file mode 100644 index cdfe0d82..00000000 --- a/packages/puppeteer-core/src/api/locators/Locator.ts +++ /dev/null @@ -1,770 +0,0 @@ -/** - * 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 { - EMPTY, - type Observable, - type OperatorFunction, - catchError, - defaultIfEmpty, - defer, - filter, - first, - firstValueFrom, - from, - fromEvent, - identity, - ignoreElements, - map, - merge, - mergeMap, - noop, - pipe, - raceWith, - retry, - tap, -} from '../../../third_party/rxjs/rxjs.js'; -import {EventEmitter, type EventType} from '../../common/EventEmitter.js'; -import type {HandleFor} from '../../common/types.js'; -import {debugError, timeout} from '../../common/util.js'; -import type { - BoundingBox, - ClickOptions, - ElementHandle, -} from '../ElementHandle.js'; - -import { - type Action, - type AwaitedLocator, - FilteredLocator, - type HandleMapper, - MappedLocator, - type Mapper, - type Predicate, - RaceLocator, -} from './locators.js'; - -/** - * For observables coming from promises, a delay is needed, otherwise RxJS will - * never yield in a permanent failure for a promise. - * - * We also don't want RxJS to do promise operations to often, so we bump the - * delay up to 100ms. - * - * @internal - */ -export const RETRY_DELAY = 100; - -/** - * @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 LocatorEvent { - /** - * Emitted every time before the locator performs an action on the located element(s). - */ - Action = 'action', -} - -export { - /** - * @deprecated Use {@link LocatorEvent}. - */ - LocatorEvent as LocatorEmittedEvents, -}; - -/** - * @public - */ -export interface LocatorEvents extends Record { - [LocatorEvent.Action]: undefined; -} - -export type { - /** - * @deprecated Use {@link LocatorEvents}. - */ - LocatorEvents as LocatorEventObject, -}; - -/** - * Locators describe a strategy of locating objects and performing an action on - * them. If the action fails because the object 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 { - /** - * Creates a race between multiple locators but ensures that only a single one - * acts. - * - * @public - */ - static race( - locators: Locators - ): Locator> { - return RaceLocator.create(locators); - } - - /** - * Used for nominally typing {@link Locator}. - */ - declare _?: T; - - /** - * @internal - */ - protected visibility: VisibilityOption = null; - /** - * @internal - */ - protected _timeout = 30_000; - #ensureElementIsInTheViewport = true; - #waitForEnabled = true; - #waitForStableBoundingBox = true; - - /** - * @internal - */ - protected operators = { - conditions: ( - conditions: Array>, - signal?: AbortSignal - ): OperatorFunction, HandleFor> => { - return mergeMap((handle: HandleFor) => { - return merge( - ...conditions.map(condition => { - return condition(handle, signal); - }) - ).pipe(defaultIfEmpty(handle)); - }); - }, - retryAndRaceWithSignalAndTimer: ( - signal?: AbortSignal - ): OperatorFunction => { - const candidates = []; - if (signal) { - candidates.push( - fromEvent(signal, 'abort').pipe( - map(() => { - throw signal.reason; - }) - ) - ); - } - candidates.push(timeout(this._timeout)); - return pipe( - retry({delay: RETRY_DELAY}), - raceWith(...candidates) - ); - }, - }; - - // Determines when the locator will timeout for actions. - get timeout(): number { - return this._timeout; - } - - setTimeout(timeout: number): Locator { - const locator = this._clone(); - locator._timeout = timeout; - return locator; - } - - setVisibility( - this: Locator, - visibility: VisibilityOption - ): Locator { - const locator = this._clone(); - locator.visibility = visibility; - return locator; - } - - setWaitForEnabled( - this: Locator, - value: boolean - ): Locator { - const locator = this._clone(); - locator.#waitForEnabled = value; - return locator; - } - - setEnsureElementIsInTheViewport( - this: Locator, - value: boolean - ): Locator { - const locator = this._clone(); - locator.#ensureElementIsInTheViewport = value; - return locator; - } - - setWaitForStableBoundingBox( - this: Locator, - value: boolean - ): Locator { - const locator = this._clone(); - locator.#waitForStableBoundingBox = value; - return locator; - } - - /** - * @internal - */ - copyOptions(locator: Locator): this { - this._timeout = locator._timeout; - this.visibility = locator.visibility; - this.#waitForEnabled = locator.#waitForEnabled; - this.#ensureElementIsInTheViewport = locator.#ensureElementIsInTheViewport; - this.#waitForStableBoundingBox = locator.#waitForStableBoundingBox; - return this; - } - - /** - * If the element has a "disabled" property, wait for the element to be - * enabled. - */ - #waitForEnabledIfNeeded = ( - handle: HandleFor, - signal?: AbortSignal - ): Observable => { - if (!this.#waitForEnabled) { - return EMPTY; - } - return from( - handle.frame.waitForFunction( - element => { - if (!(element instanceof HTMLElement)) { - return true; - } - const isNativeFormControl = [ - 'BUTTON', - 'INPUT', - 'SELECT', - 'TEXTAREA', - 'OPTION', - 'OPTGROUP', - ].includes(element.nodeName); - return !isNativeFormControl || !element.hasAttribute('disabled'); - }, - { - timeout: this._timeout, - signal, - }, - handle - ) - ).pipe(ignoreElements()); - }; - - /** - * Compares the bounding box of the element for two consecutive animation - * frames and waits till they are the same. - */ - #waitForStableBoundingBoxIfNeeded = ( - handle: HandleFor - ): Observable => { - if (!this.#waitForStableBoundingBox) { - return EMPTY; - } - return defer(() => { - // Note we don't use waitForFunction because that relies on RAF. - return from( - handle.evaluate(element => { - return new Promise<[BoundingBox, BoundingBox]>(resolve => { - window.requestAnimationFrame(() => { - const rect1 = element.getBoundingClientRect(); - window.requestAnimationFrame(() => { - const rect2 = element.getBoundingClientRect(); - resolve([ - { - x: rect1.x, - y: rect1.y, - width: rect1.width, - height: rect1.height, - }, - { - x: rect2.x, - y: rect2.y, - width: rect2.width, - height: rect2.height, - }, - ]); - }); - }); - }); - }) - ); - }).pipe( - first(([rect1, rect2]) => { - return ( - rect1.x === rect2.x && - rect1.y === rect2.y && - rect1.width === rect2.width && - rect1.height === rect2.height - ); - }), - retry({delay: RETRY_DELAY}), - ignoreElements() - ); - }; - - /** - * Checks if the element is in the viewport and auto-scrolls it if it is not. - */ - #ensureElementIsInTheViewportIfNeeded = ( - handle: HandleFor - ): Observable => { - if (!this.#ensureElementIsInTheViewport) { - return EMPTY; - } - return from(handle.isIntersectingViewport({threshold: 0})).pipe( - filter(isIntersectingViewport => { - return !isIntersectingViewport; - }), - mergeMap(() => { - return from(handle.scrollIntoView()); - }), - mergeMap(() => { - return defer(() => { - return from(handle.isIntersectingViewport({threshold: 0})); - }).pipe(first(identity), retry({delay: RETRY_DELAY}), ignoreElements()); - }) - ); - }; - - #click( - this: Locator, - options?: Readonly - ): Observable { - const signal = options?.signal; - return this._wait(options).pipe( - this.operators.conditions( - [ - this.#ensureElementIsInTheViewportIfNeeded, - this.#waitForStableBoundingBoxIfNeeded, - this.#waitForEnabledIfNeeded, - ], - signal - ), - tap(() => { - return this.emit(LocatorEvent.Action, undefined); - }), - mergeMap(handle => { - return from(handle.click(options)).pipe( - catchError(err => { - void handle.dispose().catch(debugError); - throw err; - }) - ); - }), - this.operators.retryAndRaceWithSignalAndTimer(signal) - ); - } - - #fill( - this: Locator, - value: string, - options?: Readonly - ): Observable { - const signal = options?.signal; - return this._wait(options).pipe( - this.operators.conditions( - [ - this.#ensureElementIsInTheViewportIfNeeded, - this.#waitForStableBoundingBoxIfNeeded, - this.#waitForEnabledIfNeeded, - ], - signal - ), - tap(() => { - return this.emit(LocatorEvent.Action, undefined); - }), - mergeMap(handle => { - return from( - (handle as unknown as ElementHandle).evaluate(el => { - if (el instanceof HTMLSelectElement) { - return 'select'; - } - if (el instanceof HTMLTextAreaElement) { - return 'typeable-input'; - } - if (el instanceof HTMLInputElement) { - if ( - new Set([ - 'textarea', - 'text', - 'url', - 'tel', - 'search', - 'password', - 'number', - 'email', - ]).has(el.type) - ) { - return 'typeable-input'; - } else { - return 'other-input'; - } - } - - if (el.isContentEditable) { - return 'contenteditable'; - } - - return 'unknown'; - }) - ) - .pipe( - mergeMap(inputType => { - switch (inputType) { - case 'select': - return from(handle.select(value).then(noop)); - case 'contenteditable': - case 'typeable-input': - return from( - ( - handle as unknown as ElementHandle - ).evaluate((input, newValue) => { - const currentValue = input.isContentEditable - ? input.innerText - : input.value; - - // Clear the input if the current value does not match the filled - // out value. - if ( - newValue.length <= currentValue.length || - !newValue.startsWith(input.value) - ) { - if (input.isContentEditable) { - input.innerText = ''; - } else { - input.value = ''; - } - return newValue; - } - const originalValue = input.isContentEditable - ? input.innerText - : input.value; - - // If the value is partially filled out, only type the rest. Move - // cursor to the end of the common prefix. - if (input.isContentEditable) { - input.innerText = ''; - input.innerText = originalValue; - } else { - input.value = ''; - input.value = originalValue; - } - return newValue.substring(originalValue.length); - }, value) - ).pipe( - mergeMap(textToType => { - return from(handle.type(textToType)); - }) - ); - case 'other-input': - return from(handle.focus()).pipe( - mergeMap(() => { - return from( - handle.evaluate((input, value) => { - (input as HTMLInputElement).value = value; - input.dispatchEvent( - new Event('input', {bubbles: true}) - ); - input.dispatchEvent( - new Event('change', {bubbles: true}) - ); - }, value) - ); - }) - ); - case 'unknown': - throw new Error(`Element cannot be filled out.`); - } - }) - ) - .pipe( - catchError(err => { - void handle.dispose().catch(debugError); - throw err; - }) - ); - }), - this.operators.retryAndRaceWithSignalAndTimer(signal) - ); - } - - #hover( - this: Locator, - options?: Readonly - ): Observable { - const signal = options?.signal; - return this._wait(options).pipe( - this.operators.conditions( - [ - this.#ensureElementIsInTheViewportIfNeeded, - this.#waitForStableBoundingBoxIfNeeded, - ], - signal - ), - tap(() => { - return this.emit(LocatorEvent.Action, undefined); - }), - mergeMap(handle => { - return from(handle.hover()).pipe( - catchError(err => { - void handle.dispose().catch(debugError); - throw err; - }) - ); - }), - this.operators.retryAndRaceWithSignalAndTimer(signal) - ); - } - - #scroll( - this: Locator, - options?: Readonly - ): Observable { - const signal = options?.signal; - return this._wait(options).pipe( - this.operators.conditions( - [ - this.#ensureElementIsInTheViewportIfNeeded, - this.#waitForStableBoundingBoxIfNeeded, - ], - signal - ), - tap(() => { - return this.emit(LocatorEvent.Action, undefined); - }), - mergeMap(handle => { - return from( - handle.evaluate( - (el, scrollTop, scrollLeft) => { - if (scrollTop !== undefined) { - el.scrollTop = scrollTop; - } - if (scrollLeft !== undefined) { - el.scrollLeft = scrollLeft; - } - }, - options?.scrollTop, - options?.scrollLeft - ) - ).pipe( - catchError(err => { - void handle.dispose().catch(debugError); - throw err; - }) - ); - }), - this.operators.retryAndRaceWithSignalAndTimer(signal) - ); - } - - /** - * @internal - */ - abstract _clone(): Locator; - - /** - * @internal - */ - abstract _wait(options?: Readonly): Observable>; - - /** - * Clones the locator. - */ - clone(): Locator { - return this._clone(); - } - - /** - * Waits for the locator to get a handle from the page. - * - * @public - */ - async waitHandle(options?: Readonly): Promise> { - return await firstValueFrom( - this._wait(options).pipe( - this.operators.retryAndRaceWithSignalAndTimer(options?.signal) - ) - ); - } - - /** - * Waits for the locator to get the serialized value from the page. - * - * Note this requires the value to be JSON-serializable. - * - * @public - */ - async wait(options?: Readonly): Promise { - using handle = await this.waitHandle(options); - return await handle.jsonValue(); - } - - /** - * Maps the locator using the provided mapper. - * - * @public - */ - map(mapper: Mapper): Locator { - return new MappedLocator(this._clone(), handle => { - // SAFETY: TypeScript cannot deduce the type. - return (handle as any).evaluateHandle(mapper); - }); - } - - /** - * Creates an expectation that is evaluated against located values. - * - * If the expectations do not match, then the locator will retry. - * - * @public - */ - filter(predicate: Predicate): Locator { - return new FilteredLocator(this._clone(), async (handle, signal) => { - await (handle as ElementHandle).frame.waitForFunction( - predicate, - {signal, timeout: this._timeout}, - handle - ); - return true; - }); - } - - /** - * Creates an expectation that is evaluated against located handles. - * - * If the expectations do not match, then the locator will retry. - * - * @internal - */ - filterHandle( - predicate: Predicate, HandleFor> - ): Locator { - return new FilteredLocator(this._clone(), predicate); - } - - /** - * Maps the locator using the provided mapper. - * - * @internal - */ - mapHandle(mapper: HandleMapper): Locator { - return new MappedLocator(this._clone(), mapper); - } - - click( - this: Locator, - options?: Readonly - ): Promise { - return firstValueFrom(this.#click(options)); - } - - /** - * 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: Locator, - value: string, - options?: Readonly - ): Promise { - return firstValueFrom(this.#fill(value, options)); - } - - hover( - this: Locator, - options?: Readonly - ): Promise { - return firstValueFrom(this.#hover(options)); - } - - scroll( - this: Locator, - options?: Readonly - ): Promise { - return firstValueFrom(this.#scroll(options)); - } -} diff --git a/packages/puppeteer-core/src/api/locators/MappedLocator.ts b/packages/puppeteer-core/src/api/locators/MappedLocator.ts deleted file mode 100644 index d9316813..00000000 --- a/packages/puppeteer-core/src/api/locators/MappedLocator.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * 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 { - type Observable, - from, - mergeMap, -} from '../../../third_party/rxjs/rxjs.js'; -import type {Awaitable, HandleFor} from '../../common/types.js'; - -import { - type ActionOptions, - DelegatedLocator, - type Locator, -} from './locators.js'; - -/** - * @public - */ -export type Mapper = (value: From) => Awaitable; - -/** - * @internal - */ -export type HandleMapper = ( - value: HandleFor, - signal?: AbortSignal -) => Awaitable>; - -/** - * @internal - */ -export class MappedLocator extends DelegatedLocator { - #mapper: HandleMapper; - - constructor(base: Locator, mapper: HandleMapper) { - super(base); - this.#mapper = mapper; - } - - override _clone(): MappedLocator { - return new MappedLocator(this.delegate.clone(), this.#mapper).copyOptions( - this - ); - } - - override _wait(options?: Readonly): Observable> { - return this.delegate._wait(options).pipe( - mergeMap(handle => { - return from(Promise.resolve(this.#mapper(handle, options?.signal))); - }) - ); - } -} diff --git a/packages/puppeteer-core/src/api/locators/NodeLocator.ts b/packages/puppeteer-core/src/api/locators/NodeLocator.ts deleted file mode 100644 index 78e35547..00000000 --- a/packages/puppeteer-core/src/api/locators/NodeLocator.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * 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 { - EMPTY, - type Observable, - defer, - filter, - first, - from, - identity, - ignoreElements, - retry, - throwIfEmpty, -} from '../../../third_party/rxjs/rxjs.js'; -import type {HandleFor, NodeFor} from '../../common/types.js'; -import type {Frame} from '../Frame.js'; -import type {Page} from '../Page.js'; - -import {type ActionOptions, Locator, RETRY_DELAY} from './locators.js'; - -/** - * @internal - */ -export type Action = ( - element: HandleFor, - signal?: AbortSignal -) => Observable; - -/** - * @internal - */ -export class NodeLocator extends Locator { - static create( - pageOrFrame: Page | Frame, - selector: Selector - ): Locator> { - return new NodeLocator>(pageOrFrame, selector).setTimeout( - 'getDefaultTimeout' in pageOrFrame - ? pageOrFrame.getDefaultTimeout() - : pageOrFrame.page().getDefaultTimeout() - ); - } - - #pageOrFrame: Page | Frame; - #selector: string; - - private constructor(pageOrFrame: Page | Frame, selector: string) { - super(); - - this.#pageOrFrame = pageOrFrame; - this.#selector = selector; - } - - /** - * Waits for the element to become visible or hidden. visibility === 'visible' - * means that the element has a computed style, the visibility property other - * than 'hidden' or 'collapse' and non-empty bounding box. visibility === - * 'hidden' means the opposite of that. - */ - #waitForVisibilityIfNeeded = (handle: HandleFor): Observable => { - if (!this.visibility) { - return EMPTY; - } - - return (() => { - switch (this.visibility) { - case 'hidden': - return defer(() => { - return from(handle.isHidden()); - }); - case 'visible': - return defer(() => { - return from(handle.isVisible()); - }); - } - })().pipe(first(identity), retry({delay: RETRY_DELAY}), ignoreElements()); - }; - - override _clone(): NodeLocator { - return new NodeLocator(this.#pageOrFrame, this.#selector).copyOptions( - this - ); - } - - override _wait(options?: Readonly): Observable> { - const signal = options?.signal; - return defer(() => { - return from( - this.#pageOrFrame.waitForSelector(this.#selector, { - visible: false, - timeout: this._timeout, - signal, - }) as Promise | null> - ); - }).pipe( - filter((value): value is NonNullable => { - return value !== null; - }), - throwIfEmpty(), - this.operators.conditions([this.#waitForVisibilityIfNeeded], signal) - ); - } -} diff --git a/packages/puppeteer-core/src/api/locators/RaceLocator.ts b/packages/puppeteer-core/src/api/locators/RaceLocator.ts deleted file mode 100644 index ab7bbd21..00000000 --- a/packages/puppeteer-core/src/api/locators/RaceLocator.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * 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 {type Observable, race} from '../../../third_party/rxjs/rxjs.js'; -import type {HandleFor} from '../../common/types.js'; - -import {type ActionOptions, Locator} from './locators.js'; - -/** - * @public - */ -export type AwaitedLocator = T extends Locator ? S : never; - -function checkLocatorArray( - locators: T -): ReadonlyArray>> { - for (const locator of locators) { - if (!(locator instanceof Locator)) { - throw new Error('Unknown locator for race candidate'); - } - } - return locators as ReadonlyArray>>; -} - -/** - * @internal - */ -export class RaceLocator extends Locator { - static create( - locators: T - ): Locator> { - const array = checkLocatorArray(locators); - return new RaceLocator(array); - } - - #locators: ReadonlyArray>; - - constructor(locators: ReadonlyArray>) { - super(); - this.#locators = locators; - } - - override _clone(): RaceLocator { - return new RaceLocator( - this.#locators.map(locator => { - return locator.clone(); - }) - ).copyOptions(this); - } - - override _wait(options?: Readonly): Observable> { - return race( - ...this.#locators.map(locator => { - return locator._wait(options); - }) - ); - } -} diff --git a/packages/puppeteer-core/src/api/locators/locators.ts b/packages/puppeteer-core/src/api/locators/locators.ts index 0a762b62..a504f007 100644 --- a/packages/puppeteer-core/src/api/locators/locators.ts +++ b/packages/puppeteer-core/src/api/locators/locators.ts @@ -13,15 +13,1086 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import type {EventType} from '../../../third_party/mitt/mitt.js'; +import type { + Observable, + OperatorFunction, +} from '../../../third_party/rxjs/rxjs.js'; +import { + mergeMap, + from, + EMPTY, + defer, + filter, + first, + identity, + ignoreElements, + retry, + throwIfEmpty, + race, + catchError, + defaultIfEmpty, + firstValueFrom, + fromEvent, + map, + merge, + noop, + pipe, + raceWith, + tap, +} from '../../../third_party/rxjs/rxjs.js'; +import {EventEmitter} from '../../common/EventEmitter.js'; +import type {Awaitable, HandleFor, NodeFor} from '../../common/types.js'; +import {debugError, timeout} from '../../common/util.js'; +import type { + BoundingBox, + ClickOptions, + ElementHandle, +} from '../ElementHandle.js'; +import type {Frame} from '../Frame.js'; +import type {Page} from '../Page.js'; /** - * Order of exports matters - * Don't sort + * @public */ -export * from './Locator.js'; -export * from './DelegatedLocator.js'; -export * from './FilteredLocator.js'; -export * from './FunctionLocator.js'; -export * from './MappedLocator.js'; -export * from './NodeLocator.js'; -export * from './RaceLocator.js'; +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 LocatorEvent { + /** + * Emitted every time before the locator performs an action on the located element(s). + */ + Action = 'action', +} +export { + /** + * @deprecated Use {@link LocatorEvent}. + */ + LocatorEvent as LocatorEmittedEvents, +}; +/** + * @public + */ +export interface LocatorEvents extends Record { + [LocatorEvent.Action]: undefined; +} +export type { + /** + * @deprecated Use {@link LocatorEvents}. + */ + LocatorEvents as LocatorEventObject, +}; +/** + * Locators describe a strategy of locating objects and performing an action on + * them. If the action fails because the object 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 { + /** + * Creates a race between multiple locators but ensures that only a single one + * acts. + * + * @public + */ + static race( + locators: Locators + ): Locator> { + return RaceLocator.create(locators); + } + + /** + * Used for nominally typing {@link Locator}. + */ + declare _?: T; + + /** + * @internal + */ + protected visibility: VisibilityOption = null; + /** + * @internal + */ + protected _timeout = 30000; + #ensureElementIsInTheViewport = true; + #waitForEnabled = true; + #waitForStableBoundingBox = true; + + /** + * @internal + */ + protected operators = { + conditions: ( + conditions: Array>, + signal?: AbortSignal + ): OperatorFunction, HandleFor> => { + return mergeMap((handle: HandleFor) => { + return merge( + ...conditions.map(condition => { + return condition(handle, signal); + }) + ).pipe(defaultIfEmpty(handle)); + }); + }, + retryAndRaceWithSignalAndTimer: ( + signal?: AbortSignal + ): OperatorFunction => { + const candidates = []; + if (signal) { + candidates.push( + fromEvent(signal, 'abort').pipe( + map(() => { + throw signal.reason; + }) + ) + ); + } + candidates.push(timeout(this._timeout)); + return pipe( + retry({delay: RETRY_DELAY}), + raceWith(...candidates) + ); + }, + }; + + // Determines when the locator will timeout for actions. + get timeout(): number { + return this._timeout; + } + + setTimeout(timeout: number): Locator { + const locator = this._clone(); + locator._timeout = timeout; + return locator; + } + + setVisibility( + this: Locator, + visibility: VisibilityOption + ): Locator { + const locator = this._clone(); + locator.visibility = visibility; + return locator; + } + + setWaitForEnabled( + this: Locator, + value: boolean + ): Locator { + const locator = this._clone(); + locator.#waitForEnabled = value; + return locator; + } + + setEnsureElementIsInTheViewport( + this: Locator, + value: boolean + ): Locator { + const locator = this._clone(); + locator.#ensureElementIsInTheViewport = value; + return locator; + } + + setWaitForStableBoundingBox( + this: Locator, + value: boolean + ): Locator { + const locator = this._clone(); + locator.#waitForStableBoundingBox = value; + return locator; + } + + /** + * @internal + */ + copyOptions(locator: Locator): this { + this._timeout = locator._timeout; + this.visibility = locator.visibility; + this.#waitForEnabled = locator.#waitForEnabled; + this.#ensureElementIsInTheViewport = locator.#ensureElementIsInTheViewport; + this.#waitForStableBoundingBox = locator.#waitForStableBoundingBox; + return this; + } + + /** + * If the element has a "disabled" property, wait for the element to be + * enabled. + */ + #waitForEnabledIfNeeded = ( + handle: HandleFor, + signal?: AbortSignal + ): Observable => { + if (!this.#waitForEnabled) { + return EMPTY; + } + return from( + handle.frame.waitForFunction( + element => { + if (!(element instanceof HTMLElement)) { + return true; + } + const isNativeFormControl = [ + 'BUTTON', + 'INPUT', + 'SELECT', + 'TEXTAREA', + 'OPTION', + 'OPTGROUP', + ].includes(element.nodeName); + return !isNativeFormControl || !element.hasAttribute('disabled'); + }, + { + timeout: this._timeout, + signal, + }, + handle + ) + ).pipe(ignoreElements()); + }; + + /** + * Compares the bounding box of the element for two consecutive animation + * frames and waits till they are the same. + */ + #waitForStableBoundingBoxIfNeeded = ( + handle: HandleFor + ): Observable => { + if (!this.#waitForStableBoundingBox) { + return EMPTY; + } + return defer(() => { + // Note we don't use waitForFunction because that relies on RAF. + return from( + handle.evaluate(element => { + return new Promise<[BoundingBox, BoundingBox]>(resolve => { + window.requestAnimationFrame(() => { + const rect1 = element.getBoundingClientRect(); + window.requestAnimationFrame(() => { + const rect2 = element.getBoundingClientRect(); + resolve([ + { + x: rect1.x, + y: rect1.y, + width: rect1.width, + height: rect1.height, + }, + { + x: rect2.x, + y: rect2.y, + width: rect2.width, + height: rect2.height, + }, + ]); + }); + }); + }); + }) + ); + }).pipe( + first(([rect1, rect2]) => { + return ( + rect1.x === rect2.x && + rect1.y === rect2.y && + rect1.width === rect2.width && + rect1.height === rect2.height + ); + }), + retry({delay: RETRY_DELAY}), + ignoreElements() + ); + }; + + /** + * Checks if the element is in the viewport and auto-scrolls it if it is not. + */ + #ensureElementIsInTheViewportIfNeeded = ( + handle: HandleFor + ): Observable => { + if (!this.#ensureElementIsInTheViewport) { + return EMPTY; + } + return from(handle.isIntersectingViewport({threshold: 0})).pipe( + filter(isIntersectingViewport => { + return !isIntersectingViewport; + }), + mergeMap(() => { + return from(handle.scrollIntoView()); + }), + mergeMap(() => { + return defer(() => { + return from(handle.isIntersectingViewport({threshold: 0})); + }).pipe(first(identity), retry({delay: RETRY_DELAY}), ignoreElements()); + }) + ); + }; + + #click( + this: Locator, + options?: Readonly + ): Observable { + const signal = options?.signal; + return this._wait(options).pipe( + this.operators.conditions( + [ + this.#ensureElementIsInTheViewportIfNeeded, + this.#waitForStableBoundingBoxIfNeeded, + this.#waitForEnabledIfNeeded, + ], + signal + ), + tap(() => { + return this.emit(LocatorEvent.Action, undefined); + }), + mergeMap(handle => { + return from(handle.click(options)).pipe( + catchError(err => { + void handle.dispose().catch(debugError); + throw err; + }) + ); + }), + this.operators.retryAndRaceWithSignalAndTimer(signal) + ); + } + + #fill( + this: Locator, + value: string, + options?: Readonly + ): Observable { + const signal = options?.signal; + return this._wait(options).pipe( + this.operators.conditions( + [ + this.#ensureElementIsInTheViewportIfNeeded, + this.#waitForStableBoundingBoxIfNeeded, + this.#waitForEnabledIfNeeded, + ], + signal + ), + tap(() => { + return this.emit(LocatorEvent.Action, undefined); + }), + mergeMap(handle => { + return from( + (handle as unknown as ElementHandle).evaluate(el => { + if (el instanceof HTMLSelectElement) { + return 'select'; + } + if (el instanceof HTMLTextAreaElement) { + return 'typeable-input'; + } + if (el instanceof HTMLInputElement) { + if ( + new Set([ + 'textarea', + 'text', + 'url', + 'tel', + 'search', + 'password', + 'number', + 'email', + ]).has(el.type) + ) { + return 'typeable-input'; + } else { + return 'other-input'; + } + } + + if (el.isContentEditable) { + return 'contenteditable'; + } + + return 'unknown'; + }) + ) + .pipe( + mergeMap(inputType => { + switch (inputType) { + case 'select': + return from(handle.select(value).then(noop)); + case 'contenteditable': + case 'typeable-input': + return from( + ( + handle as unknown as ElementHandle + ).evaluate((input, newValue) => { + const currentValue = input.isContentEditable + ? input.innerText + : input.value; + + // Clear the input if the current value does not match the filled + // out value. + if ( + newValue.length <= currentValue.length || + !newValue.startsWith(input.value) + ) { + if (input.isContentEditable) { + input.innerText = ''; + } else { + input.value = ''; + } + return newValue; + } + const originalValue = input.isContentEditable + ? input.innerText + : input.value; + + // If the value is partially filled out, only type the rest. Move + // cursor to the end of the common prefix. + if (input.isContentEditable) { + input.innerText = ''; + input.innerText = originalValue; + } else { + input.value = ''; + input.value = originalValue; + } + return newValue.substring(originalValue.length); + }, value) + ).pipe( + mergeMap(textToType => { + return from(handle.type(textToType)); + }) + ); + case 'other-input': + return from(handle.focus()).pipe( + mergeMap(() => { + return from( + handle.evaluate((input, value) => { + (input as HTMLInputElement).value = value; + input.dispatchEvent( + new Event('input', {bubbles: true}) + ); + input.dispatchEvent( + new Event('change', {bubbles: true}) + ); + }, value) + ); + }) + ); + case 'unknown': + throw new Error(`Element cannot be filled out.`); + } + }) + ) + .pipe( + catchError(err => { + void handle.dispose().catch(debugError); + throw err; + }) + ); + }), + this.operators.retryAndRaceWithSignalAndTimer(signal) + ); + } + + #hover( + this: Locator, + options?: Readonly + ): Observable { + const signal = options?.signal; + return this._wait(options).pipe( + this.operators.conditions( + [ + this.#ensureElementIsInTheViewportIfNeeded, + this.#waitForStableBoundingBoxIfNeeded, + ], + signal + ), + tap(() => { + return this.emit(LocatorEvent.Action, undefined); + }), + mergeMap(handle => { + return from(handle.hover()).pipe( + catchError(err => { + void handle.dispose().catch(debugError); + throw err; + }) + ); + }), + this.operators.retryAndRaceWithSignalAndTimer(signal) + ); + } + + #scroll( + this: Locator, + options?: Readonly + ): Observable { + const signal = options?.signal; + return this._wait(options).pipe( + this.operators.conditions( + [ + this.#ensureElementIsInTheViewportIfNeeded, + this.#waitForStableBoundingBoxIfNeeded, + ], + signal + ), + tap(() => { + return this.emit(LocatorEvent.Action, undefined); + }), + mergeMap(handle => { + return from( + handle.evaluate( + (el, scrollTop, scrollLeft) => { + if (scrollTop !== undefined) { + el.scrollTop = scrollTop; + } + if (scrollLeft !== undefined) { + el.scrollLeft = scrollLeft; + } + }, + options?.scrollTop, + options?.scrollLeft + ) + ).pipe( + catchError(err => { + void handle.dispose().catch(debugError); + throw err; + }) + ); + }), + this.operators.retryAndRaceWithSignalAndTimer(signal) + ); + } + + /** + * @internal + */ + abstract _clone(): Locator; + + /** + * @internal + */ + abstract _wait(options?: Readonly): Observable>; + + /** + * Clones the locator. + */ + clone(): Locator { + return this._clone(); + } + + /** + * Waits for the locator to get a handle from the page. + * + * @public + */ + async waitHandle(options?: Readonly): Promise> { + return await firstValueFrom( + this._wait(options).pipe( + this.operators.retryAndRaceWithSignalAndTimer(options?.signal) + ) + ); + } + + /** + * Waits for the locator to get the serialized value from the page. + * + * Note this requires the value to be JSON-serializable. + * + * @public + */ + async wait(options?: Readonly): Promise { + using handle = await this.waitHandle(options); + return await handle.jsonValue(); + } + + /** + * Maps the locator using the provided mapper. + * + * @public + */ + map(mapper: Mapper): Locator { + return new MappedLocator(this._clone(), handle => { + // SAFETY: TypeScript cannot deduce the type. + return (handle as any).evaluateHandle(mapper); + }); + } + + /** + * Creates an expectation that is evaluated against located values. + * + * If the expectations do not match, then the locator will retry. + * + * @public + */ + filter(predicate: Predicate): Locator { + return new FilteredLocator(this._clone(), async (handle, signal) => { + await (handle as ElementHandle).frame.waitForFunction( + predicate, + {signal, timeout: this._timeout}, + handle + ); + return true; + }); + } + + /** + * Creates an expectation that is evaluated against located handles. + * + * If the expectations do not match, then the locator will retry. + * + * @internal + */ + filterHandle( + predicate: Predicate, HandleFor> + ): Locator { + return new FilteredLocator(this._clone(), predicate); + } + + /** + * Maps the locator using the provided mapper. + * + * @internal + */ + mapHandle(mapper: HandleMapper): Locator { + return new MappedLocator(this._clone(), mapper); + } + + click( + this: Locator, + options?: Readonly + ): Promise { + return firstValueFrom(this.#click(options)); + } + + /** + * 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: Locator, + value: string, + options?: Readonly + ): Promise { + return firstValueFrom(this.#fill(value, options)); + } + + hover( + this: Locator, + options?: Readonly + ): Promise { + return firstValueFrom(this.#hover(options)); + } + + scroll( + this: Locator, + options?: Readonly + ): Promise { + return firstValueFrom(this.#scroll(options)); + } +} + +/** + * @internal + */ +export class FunctionLocator extends Locator { + static create( + pageOrFrame: Page | Frame, + func: () => Awaitable + ): Locator { + return new FunctionLocator(pageOrFrame, func).setTimeout( + 'getDefaultTimeout' in pageOrFrame + ? pageOrFrame.getDefaultTimeout() + : pageOrFrame.page().getDefaultTimeout() + ); + } + + #pageOrFrame: Page | Frame; + #func: () => Awaitable; + + private constructor(pageOrFrame: Page | Frame, func: () => Awaitable) { + super(); + + this.#pageOrFrame = pageOrFrame; + this.#func = func; + } + + override _clone(): FunctionLocator { + return new FunctionLocator(this.#pageOrFrame, this.#func); + } + + _wait(options?: Readonly): Observable> { + const signal = options?.signal; + return defer(() => { + return from( + this.#pageOrFrame.waitForFunction(this.#func, { + timeout: this.timeout, + signal, + }) + ); + }).pipe(throwIfEmpty()); + } +} + +/** + * @public + */ +export type Predicate = + | ((value: From) => value is To) + | ((value: From) => Awaitable); +/** + * @internal + */ +export type HandlePredicate = + | ((value: HandleFor, signal?: AbortSignal) => value is HandleFor) + | ((value: HandleFor, signal?: AbortSignal) => Awaitable); + +/** + * @internal + */ +export abstract class DelegatedLocator extends Locator { + #delegate: Locator; + + constructor(delegate: Locator) { + super(); + + this.#delegate = delegate; + this.copyOptions(this.#delegate); + } + + protected get delegate(): Locator { + return this.#delegate; + } + + override setTimeout(timeout: number): DelegatedLocator { + const locator = super.setTimeout(timeout) as DelegatedLocator; + locator.#delegate = this.#delegate.setTimeout(timeout); + return locator; + } + + override setVisibility( + this: DelegatedLocator, + visibility: VisibilityOption + ): DelegatedLocator { + const locator = super.setVisibility( + visibility + ) as DelegatedLocator; + locator.#delegate = locator.#delegate.setVisibility(visibility); + return locator; + } + + override setWaitForEnabled( + this: DelegatedLocator, + value: boolean + ): DelegatedLocator { + const locator = super.setWaitForEnabled( + value + ) as DelegatedLocator; + locator.#delegate = this.#delegate.setWaitForEnabled(value); + return locator; + } + + override setEnsureElementIsInTheViewport< + ValueType extends Element, + ElementType extends Element, + >( + this: DelegatedLocator, + value: boolean + ): DelegatedLocator { + const locator = super.setEnsureElementIsInTheViewport( + value + ) as DelegatedLocator; + locator.#delegate = this.#delegate.setEnsureElementIsInTheViewport(value); + return locator; + } + + override setWaitForStableBoundingBox< + ValueType extends Element, + ElementType extends Element, + >( + this: DelegatedLocator, + value: boolean + ): DelegatedLocator { + const locator = super.setWaitForStableBoundingBox( + value + ) as DelegatedLocator; + locator.#delegate = this.#delegate.setWaitForStableBoundingBox(value); + return locator; + } + + abstract override _clone(): DelegatedLocator; + abstract override _wait(): Observable>; +} + +/** + * @internal + */ +export class FilteredLocator extends DelegatedLocator< + From, + To +> { + #predicate: HandlePredicate; + + constructor(base: Locator, predicate: HandlePredicate) { + super(base); + this.#predicate = predicate; + } + + override _clone(): FilteredLocator { + return new FilteredLocator( + this.delegate.clone(), + this.#predicate + ).copyOptions(this); + } + + override _wait(options?: Readonly): Observable> { + return this.delegate._wait(options).pipe( + mergeMap(handle => { + return from( + Promise.resolve(this.#predicate(handle, options?.signal)) + ).pipe( + filter(value => { + return value; + }), + map(() => { + // SAFETY: It passed the predicate, so this is correct. + return handle as HandleFor; + }) + ); + }), + throwIfEmpty() + ); + } +} + +/** + * @public + */ +export type Mapper = (value: From) => Awaitable; +/** + * @internal + */ +export type HandleMapper = ( + value: HandleFor, + signal?: AbortSignal +) => Awaitable>; +/** + * @internal + */ +export class MappedLocator extends DelegatedLocator { + #mapper: HandleMapper; + + constructor(base: Locator, mapper: HandleMapper) { + super(base); + this.#mapper = mapper; + } + + override _clone(): MappedLocator { + return new MappedLocator(this.delegate.clone(), this.#mapper).copyOptions( + this + ); + } + + override _wait(options?: Readonly): Observable> { + return this.delegate._wait(options).pipe( + mergeMap(handle => { + return from(Promise.resolve(this.#mapper(handle, options?.signal))); + }) + ); + } +} + +/** + * @internal + */ +export type Action = ( + element: HandleFor, + signal?: AbortSignal +) => Observable; +/** + * @internal + */ +export class NodeLocator extends Locator { + static create( + pageOrFrame: Page | Frame, + selector: Selector + ): Locator> { + return new NodeLocator>(pageOrFrame, selector).setTimeout( + 'getDefaultTimeout' in pageOrFrame + ? pageOrFrame.getDefaultTimeout() + : pageOrFrame.page().getDefaultTimeout() + ); + } + + #pageOrFrame: Page | Frame; + #selector: string; + + private constructor(pageOrFrame: Page | Frame, selector: string) { + super(); + + this.#pageOrFrame = pageOrFrame; + this.#selector = selector; + } + + /** + * Waits for the element to become visible or hidden. visibility === 'visible' + * means that the element has a computed style, the visibility property other + * than 'hidden' or 'collapse' and non-empty bounding box. visibility === + * 'hidden' means the opposite of that. + */ + #waitForVisibilityIfNeeded = (handle: HandleFor): Observable => { + if (!this.visibility) { + return EMPTY; + } + + return (() => { + switch (this.visibility) { + case 'hidden': + return defer(() => { + return from(handle.isHidden()); + }); + case 'visible': + return defer(() => { + return from(handle.isVisible()); + }); + } + })().pipe(first(identity), retry({delay: RETRY_DELAY}), ignoreElements()); + }; + + override _clone(): NodeLocator { + return new NodeLocator(this.#pageOrFrame, this.#selector).copyOptions( + this + ); + } + + override _wait(options?: Readonly): Observable> { + const signal = options?.signal; + return defer(() => { + return from( + this.#pageOrFrame.waitForSelector(this.#selector, { + visible: false, + timeout: this._timeout, + signal, + }) as Promise | null> + ); + }).pipe( + filter((value): value is NonNullable => { + return value !== null; + }), + throwIfEmpty(), + this.operators.conditions([this.#waitForVisibilityIfNeeded], signal) + ); + } +} + +/** + * @public + */ +export type AwaitedLocator = T extends Locator ? S : never; +function checkLocatorArray( + locators: T +): ReadonlyArray>> { + for (const locator of locators) { + if (!(locator instanceof Locator)) { + throw new Error('Unknown locator for race candidate'); + } + } + return locators as ReadonlyArray>>; +} +/** + * @internal + */ +export class RaceLocator extends Locator { + static create( + locators: T + ): Locator> { + const array = checkLocatorArray(locators); + return new RaceLocator(array); + } + + #locators: ReadonlyArray>; + + constructor(locators: ReadonlyArray>) { + super(); + this.#locators = locators; + } + + override _clone(): RaceLocator { + return new RaceLocator( + this.#locators.map(locator => { + return locator.clone(); + }) + ).copyOptions(this); + } + + override _wait(options?: Readonly): Observable> { + return race( + ...this.#locators.map(locator => { + return locator._wait(options); + }) + ); + } +} + +/** + * For observables coming from promises, a delay is needed, otherwise RxJS will + * never yield in a permanent failure for a promise. + * + * We also don't want RxJS to do promise operations to often, so we bump the + * delay up to 100ms. + * + * @internal + */ +export const RETRY_DELAY = 100;