/** * 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 {EventEmitter} from '../common/EventEmitter.js'; import {debugError} from '../common/util.js'; import {isErrorLike} from '../util/ErrorLike.js'; import {ElementHandle, BoundingBox, ClickOptions} from './ElementHandle.js'; import type {Page} from './Page.js'; type VisibilityOption = 'hidden' | 'visible' | null; /** * @internal */ export interface LocatorOptions { /** * Whether to wait for the element to be `visible` or `hidden`. `null` to * disable visibility checks. */ visibility: VisibilityOption; /** * Total timeout for the entire locator operation. * * Pass `0` to disable timeout. * * @defaultValue `Page.getDefaultTimeout()` */ timeout: number; /** * Whether to scroll the element into viewport if not in the viewprot already. * @defaultValue `true` */ ensureElementIsInTheViewport: boolean; /** * Whether to wait for input elements to become enabled before the action. * Applicable to `click` and `fill` actions. * @defaultValue `true` */ waitForEnabled: boolean; /** * Whether to wait for the element's bounding box to be same between two * animation frames. * @defaultValue `true` */ waitForStableBoundingBox: boolean; } /** * Timeout for individual operations inside the locator. On errors the * operation is retried as long as {@link LocatorOptions.timeout} is not * exceeded. This timeout should be generally much lower as locating an * element means multiple asynchronious operations. */ const CONDITION_TIMEOUT = 1_000; const WAIT_FOR_FUNCTION_DELAY = 100; /** * @internal */ export type ActionCondition = ( element: ElementHandle, signal: AbortSignal ) => Promise; /** * @internal */ export interface ActionOptions { signal?: AbortSignal; conditions: ActionCondition[]; } /** * All the events that a locator instance may emit. * * @internal */ export enum LocatorEmittedEvents { /** * Emitted every time before the locator performs an action on the located element(s). */ Action = 'action', } /** * @internal */ 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 are not ready for the action, * the whole operation is retried. * * @internal */ export class Locator extends EventEmitter { #page: Page; #selector: string; #options: LocatorOptions; constructor( page: Page, selector: string, options: LocatorOptions = { visibility: 'visible', timeout: page.getDefaultTimeout(), ensureElementIsInTheViewport: true, waitForEnabled: true, waitForStableBoundingBox: true, } ) { super(); this.#page = page; this.#selector = selector; this.#options = options; } 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); } setVisibility(visibility: VisibilityOption): this { this.#options.visibility = visibility; return this; } setTimeout(timeout: number): this { this.#options.timeout = timeout; return this; } setEnsureElementIsInTheViewport(value: boolean): this { this.#options.ensureElementIsInTheViewport = value; return this; } setWaitForEnabled(value: boolean): this { this.#options.waitForEnabled = value; return this; } setWaitForStableBoundingBox(value: boolean): this { this.#options.waitForStableBoundingBox = value; return this; } /** * Retries the `fn` until a truthy result is returned. */ async #waitForFunction( fn: (signal: AbortSignal) => unknown, signal?: AbortSignal, timeout = CONDITION_TIMEOUT ): Promise { let isActive = true; let controller: AbortController; // If the loop times out, we abort only the last iteration's controller. const timeoutId = timeout ? setTimeout(() => { isActive = false; controller?.abort(); }, timeout) : 0; // If the user's signal aborts, we abort the last iteration and the loop. signal?.addEventListener( 'abort', () => { controller?.abort(); isActive = false; }, {once: true} ); while (isActive) { controller = new AbortController(); try { const result = await fn(controller.signal); if (result) { clearTimeout(timeoutId); return; } } catch (err) { if (isErrorLike(err)) { debugError(err); // Retry on all timeouts. if (err instanceof TimeoutError) { continue; } // Abort error are ignored as they only affect one iteration. if (err.name === 'AbortError') { continue; } } throw err; } finally { // We abort any operations that might have been started by `fn`, because // the iteration is now over. controller.abort(); } await new Promise(resolve => { return setTimeout(resolve, WAIT_FOR_FUNCTION_DELAY); }); } signal?.throwIfAborted(); throw new TimeoutError( `waitForFunction timed out. The timeout is ${timeout}ms.` ); } /** * Checks if the element is in the viewport and auto-scrolls it if it is not. */ #ensureElementIsInTheViewport = async ( element: ElementHandle, signal?: AbortSignal ): Promise => { if (!this.#options.ensureElementIsInTheViewport) { return; } // Side-effect: this also checks if it is connected. const isIntersectingViewport = await element.isIntersectingViewport({ threshold: 0, }); signal?.throwIfAborted(); if (!isIntersectingViewport) { await element.scrollIntoView(); signal?.throwIfAborted(); await this.#waitForFunction(async () => { return await element.isIntersectingViewport({ threshold: 0, }); }, signal); signal?.throwIfAborted(); } }; /** * 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. */ #waitForVisibility = async ( element: ElementHandle, signal?: AbortSignal ): Promise => { if (this.#options.visibility === null) { return; } if (this.#options.visibility === 'hidden') { await this.#waitForFunction(async () => { return element.isHidden(); }, signal); } await this.#waitForFunction(async () => { return element.isVisible(); }, signal); }; /** * If the element is a button, textarea, input or select, wait till the * element becomes enabled. */ #waitForEnabled = async ( element: ElementHandle, signal?: AbortSignal ): Promise => { if (!this.#options.waitForEnabled) { return; } await this.#page.waitForFunction( el => { if (['button', 'textarea', 'input', 'select'].includes(el.tagName)) { return !(el as HTMLInputElement).disabled; } return true; }, { timeout: CONDITION_TIMEOUT, signal, }, element ); }; /** * Compares the bounding box of the element for two consecutive animation * frames and waits till they are the same. */ #waitForStableBoundingBox = async ( element: ElementHandle, signal?: AbortSignal ): Promise => { if (!this.#options.waitForStableBoundingBox) { return; } function getClientRect() { return element.evaluate(el => { return new Promise<[BoundingBox, BoundingBox]>(resolve => { window.requestAnimationFrame(() => { const rect1 = el.getBoundingClientRect(); window.requestAnimationFrame(() => { const rect2 = el.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, }, ]); }); }); }); }); } await this.#waitForFunction(async () => { const [rect1, rect2] = await getClientRect(); return ( rect1.x === rect2.x && rect1.y === rect2.y && rect1.width === rect2.width && rect1.height === rect2.height ); }, signal); }; async #run( action: (el: ElementHandle) => Promise, options?: ActionOptions ) { await this.#waitForFunction( async signal => { // 1. Select the element without visibility checks. const element = await this.#page.waitForSelector(this.#selector, { visible: false, timeout: this.#options.timeout, signal, }); // Retry if no element is found. if (!element) { return false; } try { signal?.throwIfAborted(); // 2. Perform action specific checks. await Promise.all( options?.conditions.map(check => { return check(element, signal); }) || [] ); signal?.throwIfAborted(); // 3. Perform the action this.emit(LocatorEmittedEvents.Action); await action(element); return true; } finally { void element.dispose().catch(debugError); } }, options?.signal, this.#options.timeout ); } async click( clickOptions?: ClickOptions & { signal?: AbortSignal; } ): Promise { return await this.#run( async element => { await element.click(clickOptions); }, { signal: clickOptions?.signal, conditions: [ this.#ensureElementIsInTheViewport, this.#waitForVisibility, this.#waitForEnabled, this.#waitForStableBoundingBox, ], } ); } /** * 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. */ async fill( value: string, fillOptions?: {signal?: AbortSignal} ): Promise { return await this.#run( async element => { const input = element as ElementHandle; const inputType = await input.evaluate(el => { if (el instanceof HTMLSelectElement) { return 'select'; } 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'; }); switch (inputType) { case 'select': await input.select(value); break; case 'contenteditable': case 'typeable-input': const textToType = await ( input 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); await input.type(textToType); break; case 'other-input': await input.focus(); await input.evaluate((input, value) => { (input as HTMLInputElement).value = value; input.dispatchEvent(new Event('input', {bubbles: true})); input.dispatchEvent(new Event('change', {bubbles: true})); }, value); break; case 'unknown': throw new Error(`Element cannot be filled out.`); } }, { signal: fillOptions?.signal, conditions: [ this.#ensureElementIsInTheViewport, this.#waitForVisibility, this.#waitForEnabled, this.#waitForStableBoundingBox, ], } ); } async hover(hoverOptions?: {signal?: AbortSignal}): Promise { return await this.#run( async element => { await element.hover(); }, { signal: hoverOptions?.signal, conditions: [ this.#ensureElementIsInTheViewport, this.#waitForVisibility, this.#waitForStableBoundingBox, ], } ); } async scroll(scrollOptions?: { scrollTop?: number; scrollLeft?: number; signal?: AbortSignal; }): Promise { return await this.#run( async element => { await element.evaluate( (el, scrollTop, scrollLeft) => { if (scrollTop !== undefined) { el.scrollTop = scrollTop; } if (scrollLeft !== undefined) { el.scrollLeft = scrollLeft; } }, scrollOptions?.scrollTop, scrollOptions?.scrollLeft ); }, { signal: scrollOptions?.signal, conditions: [ this.#ensureElementIsInTheViewport, this.#waitForVisibility, this.#waitForStableBoundingBox, ], } ); } }