/** * @license * Copyright 2023 Google Inc. * SPDX-License-Identifier: Apache-2.0 */ import type { Observable, OperatorFunction, } from '../../../third_party/rxjs/rxjs.js'; import { EMPTY, catchError, defaultIfEmpty, defer, filter, first, firstValueFrom, from, fromEvent, identity, ignoreElements, map, merge, mergeMap, noop, pipe, race, raceWith, retry, tap, throwIfEmpty, } from '../../../third_party/rxjs/rxjs.js'; import type {EventType} from '../../common/EventEmitter.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'; /** * @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 = 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;