diff --git a/packages/puppeteer-core/src/api/Locator.ts b/packages/puppeteer-core/src/api/Locator.ts new file mode 100644 index 00000000000..695de681e65 --- /dev/null +++ b/packages/puppeteer-core/src/api/Locator.ts @@ -0,0 +1,360 @@ +/** + * 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'; + +/** + * @internal + */ +export interface LocatorOptions { + /** + * Whether to wait for the element to be `visible` or `hidden`. + */ + visibility: 'hidden' | 'visible'; + /** + * Total timeout for the entire locator operation. + */ + timeout: number; +} + +/** + * 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 + */ +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(), + } + ) { + super(); + this.#page = page; + this.#selector = selector; + this.#options = options; + } + + override on( + eventName: K, + handler: (event: LocatorEventObject[K]) => void + ): Locator { + return super.on(eventName, handler) as Locator; + } + + override once( + eventName: K, + handler: (event: LocatorEventObject[K]) => void + ): Locator { + return super.once(eventName, handler) as Locator; + } + + override off( + eventName: K, + handler: (event: LocatorEventObject[K]) => void + ): Locator { + return super.off(eventName, handler) as Locator; + } + + /** + * 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 = setTimeout(() => { + isActive = false; + controller?.abort(); + }, timeout); + // 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 => { + // 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 === '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 => { + 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 => { + 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 { + await this.#run( + async element => { + await element.click(clickOptions); + }, + { + signal: clickOptions?.signal, + conditions: [ + this.#ensureElementIsInTheViewport, + this.#waitForVisibility, + this.#waitForEnabled, + this.#waitForStableBoundingBox, + ], + } + ); + } +} diff --git a/packages/puppeteer-core/src/api/Page.ts b/packages/puppeteer-core/src/api/Page.ts index 10fcbfccdaa..6110a5b4659 100644 --- a/packages/puppeteer-core/src/api/Page.ts +++ b/packages/puppeteer-core/src/api/Page.ts @@ -61,6 +61,7 @@ import type {Browser} from './Browser.js'; import type {BrowserContext} from './BrowserContext.js'; import type {ClickOptions, ElementHandle} from './ElementHandle.js'; import type {JSHandle} from './JSHandle.js'; +import {Locator} from './Locator.js'; /** * @public @@ -782,6 +783,13 @@ export class Page extends EventEmitter { throw new Error('Not implemented'); } + /** + * @internal + */ + locator(selector: string): Locator { + return new Locator(this, selector); + } + /** * Runs `document.querySelector` within the page. If no element matches the * selector, the return value resolves to `null`. diff --git a/packages/puppeteer-core/src/api/api.ts b/packages/puppeteer-core/src/api/api.ts index 704c8d127fe..ec706ea8498 100644 --- a/packages/puppeteer-core/src/api/api.ts +++ b/packages/puppeteer-core/src/api/api.ts @@ -21,3 +21,4 @@ export * from './JSHandle.js'; export * from './ElementHandle.js'; export * from './HTTPResponse.js'; export * from './HTTPRequest.js'; +export * from './Locator.js'; diff --git a/test/src/locator.spec.ts b/test/src/locator.spec.ts new file mode 100644 index 00000000000..159b23ed85b --- /dev/null +++ b/test/src/locator.spec.ts @@ -0,0 +1,229 @@ +/** + * 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 expect from 'expect'; +import {TimeoutError} from 'puppeteer-core'; +import {LocatorEmittedEvents} from 'puppeteer-core/internal/api/Locator.js'; +import sinon from 'sinon'; + +import { + getTestState, + setupTestBrowserHooks, + setupTestPageAndContextHooks, +} from './mocha-utils.js'; + +describe('Locator', function () { + setupTestBrowserHooks(); + setupTestPageAndContextHooks(); + + describe('Locator.click', function () { + it('should work', async () => { + const {page} = getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + + `); + let willClick = false; + await page + .locator('button') + .on(LocatorEmittedEvents.Action, () => { + willClick = true; + }) + .click(); + const button = await page.$('button'); + const text = await button?.evaluate(el => { + return el.innerText; + }); + expect(text).toBe('clicked'); + expect(willClick).toBe(true); + }); + + it('should work for multiple selectors', async () => { + const {page} = getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + + `); + let clicked = false; + await page + .locator('::-p-text(test), ::-p-xpath(/button)') + .on(LocatorEmittedEvents.Action, () => { + clicked = true; + }) + .click(); + const button = await page.$('button'); + const text = await button?.evaluate(el => { + return el.innerText; + }); + expect(text).toBe('clicked'); + expect(clicked).toBe(true); + }); + + it('should work if the element is out of viewport', async () => { + const {page} = getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + + `); + await page.locator('button').click(); + const button = await page.$('button'); + const text = await button?.evaluate(el => { + return el.innerText; + }); + expect(text).toBe('clicked'); + }); + + it('should work if the element becomes visible later', async () => { + const {page} = getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + + `); + const button = await page.$('button'); + const result = page.locator('button').click(); + expect( + await button?.evaluate(el => { + return el.innerText; + }) + ).toBe('test'); + await button?.evaluate(el => { + el.style.display = 'block'; + }); + await result; + expect( + await button?.evaluate(el => { + return el.innerText; + }) + ).toBe('clicked'); + }); + + it('should work if the element becomes enabled later', async () => { + const {page} = getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + + `); + const button = await page.$('button'); + const result = page.locator('button').click(); + expect( + await button?.evaluate(el => { + return el.innerText; + }) + ).toBe('test'); + await button?.evaluate(el => { + el.disabled = false; + }); + await result; + expect( + await button?.evaluate(el => { + return el.innerText; + }) + ).toBe('clicked'); + }); + + it('should work if multiple conditions are satisfied later', async () => { + const {page} = getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + + `); + const button = await page.$('button'); + const result = page.locator('button').click(); + expect( + await button?.evaluate(el => { + return el.innerText; + }) + ).toBe('test'); + await button?.evaluate(el => { + el.disabled = false; + el.style.display = 'block'; + }); + await result; + expect( + await button?.evaluate(el => { + return el.innerText; + }) + ).toBe('clicked'); + }); + + it('should time out', async () => { + const clock = sinon.useFakeTimers(); + try { + const {page} = getTestState(); + + page.setDefaultTimeout(5000); + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + + `); + const result = page.locator('button').click(); + clock.tick(5100); + await expect(result).rejects.toEqual( + new TimeoutError('waitForFunction timed out. The timeout is 5000ms.') + ); + } finally { + clock.restore(); + } + }); + + it('should retry clicks on errors', async () => { + const {page} = getTestState(); + const clock = sinon.useFakeTimers(); + try { + page.setDefaultTimeout(5000); + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + + `); + const result = page.locator('button').click(); + clock.tick(5100); + await expect(result).rejects.toEqual( + new TimeoutError('waitForFunction timed out. The timeout is 5000ms.') + ); + } finally { + clock.restore(); + } + }); + + it('can be aborted', async () => { + const {page} = getTestState(); + const clock = sinon.useFakeTimers(); + try { + page.setDefaultTimeout(5000); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + + `); + const abortController = new AbortController(); + const result = page.locator('button').click({ + signal: abortController.signal, + }); + clock.tick(2000); + abortController.abort(); + await expect(result).rejects.toThrow(/aborted/); + } finally { + clock.restore(); + } + }); + }); +});