From 9c35e9ab1f92e99aab8dabcd17f687befd6aad81 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Wed, 7 Jun 2023 12:45:02 +0200 Subject: [PATCH] feat: implement Locator.race (#10337) --- docs/api/puppeteer.locator.click.md | 2 +- docs/api/puppeteer.locator.fill.md | 2 +- docs/api/puppeteer.locator.hover.md | 2 +- docs/api/puppeteer.locator.md | 31 +-- docs/api/puppeteer.locator.race.md | 25 ++ docs/api/puppeteer.locator.scroll.md | 2 +- ...locator.setensureelementisintheviewport.md | 2 +- docs/api/puppeteer.locator.settimeout.md | 2 +- docs/api/puppeteer.locator.setvisibility.md | 2 +- .../puppeteer.locator.setwaitforenabled.md | 2 +- ...eer.locator.setwaitforstableboundingbox.md | 2 +- packages/puppeteer-core/src/api/Locator.ts | 236 ++++++++++++++++-- test/TestExpectations.json | 12 +- test/src/locator.spec.ts | 54 +++- 14 files changed, 331 insertions(+), 45 deletions(-) create mode 100644 docs/api/puppeteer.locator.race.md diff --git a/docs/api/puppeteer.locator.click.md b/docs/api/puppeteer.locator.click.md index 77f99e272bc..a9ec355d210 100644 --- a/docs/api/puppeteer.locator.click.md +++ b/docs/api/puppeteer.locator.click.md @@ -8,7 +8,7 @@ sidebar_label: Locator.click ```typescript class Locator { - click( + abstract click( clickOptions?: ClickOptions & { signal?: AbortSignal; } diff --git a/docs/api/puppeteer.locator.fill.md b/docs/api/puppeteer.locator.fill.md index dce869aa1e8..97d34aa1ac5 100644 --- a/docs/api/puppeteer.locator.fill.md +++ b/docs/api/puppeteer.locator.fill.md @@ -10,7 +10,7 @@ Fills out the input identified by the locator using the provided value. The type ```typescript class Locator { - fill( + abstract fill( value: string, fillOptions?: { signal?: AbortSignal; diff --git a/docs/api/puppeteer.locator.hover.md b/docs/api/puppeteer.locator.hover.md index d9a298e850e..e9ea8f91867 100644 --- a/docs/api/puppeteer.locator.hover.md +++ b/docs/api/puppeteer.locator.hover.md @@ -8,7 +8,7 @@ sidebar_label: Locator.hover ```typescript class Locator { - hover(hoverOptions?: {signal?: AbortSignal}): Promise; + abstract hover(hoverOptions?: {signal?: AbortSignal}): Promise; } ``` diff --git a/docs/api/puppeteer.locator.md b/docs/api/puppeteer.locator.md index 5cbefe473d1..a800bbe9381 100644 --- a/docs/api/puppeteer.locator.md +++ b/docs/api/puppeteer.locator.md @@ -9,24 +9,25 @@ Locators describe a strategy of locating elements and performing an action on th #### Signature: ```typescript -export declare class Locator extends EventEmitter +export declare abstract class Locator extends EventEmitter ``` **Extends:** [EventEmitter](./puppeteer.eventemitter.md) ## Methods -| Method | Modifiers | Description | -| ------------------------------------------------------------------------------------------------ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [click(clickOptions)](./puppeteer.locator.click.md) | | | -| [fill(value, fillOptions)](./puppeteer.locator.fill.md) | | 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. | -| [hover(hoverOptions)](./puppeteer.locator.hover.md) | | | -| [off(eventName, handler)](./puppeteer.locator.off.md) | | | -| [on(eventName, handler)](./puppeteer.locator.on.md) | | | -| [once(eventName, handler)](./puppeteer.locator.once.md) | | | -| [scroll(scrollOptions)](./puppeteer.locator.scroll.md) | | | -| [setEnsureElementIsInTheViewport(value)](./puppeteer.locator.setensureelementisintheviewport.md) | | | -| [setTimeout(timeout)](./puppeteer.locator.settimeout.md) | | | -| [setVisibility(visibility)](./puppeteer.locator.setvisibility.md) | | | -| [setWaitForEnabled(value)](./puppeteer.locator.setwaitforenabled.md) | | | -| [setWaitForStableBoundingBox(value)](./puppeteer.locator.setwaitforstableboundingbox.md) | | | +| Method | Modifiers | Description | +| ------------------------------------------------------------------------------------------------ | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [click(clickOptions)](./puppeteer.locator.click.md) | | | +| [fill(value, fillOptions)](./puppeteer.locator.fill.md) | | 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. | +| [hover(hoverOptions)](./puppeteer.locator.hover.md) | | | +| [off(eventName, handler)](./puppeteer.locator.off.md) | | | +| [on(eventName, handler)](./puppeteer.locator.on.md) | | | +| [once(eventName, handler)](./puppeteer.locator.once.md) | | | +| [race(locators)](./puppeteer.locator.race.md) | static | Creates a race between multiple locators but ensures that only a single one acts. | +| [scroll(scrollOptions)](./puppeteer.locator.scroll.md) | | | +| [setEnsureElementIsInTheViewport(value)](./puppeteer.locator.setensureelementisintheviewport.md) | | | +| [setTimeout(timeout)](./puppeteer.locator.settimeout.md) | | | +| [setVisibility(visibility)](./puppeteer.locator.setvisibility.md) | | | +| [setWaitForEnabled(value)](./puppeteer.locator.setwaitforenabled.md) | | | +| [setWaitForStableBoundingBox(value)](./puppeteer.locator.setwaitforstableboundingbox.md) | | | diff --git a/docs/api/puppeteer.locator.race.md b/docs/api/puppeteer.locator.race.md new file mode 100644 index 00000000000..dd2897f1407 --- /dev/null +++ b/docs/api/puppeteer.locator.race.md @@ -0,0 +1,25 @@ +--- +sidebar_label: Locator.race +--- + +# Locator.race() method + +Creates a race between multiple locators but ensures that only a single one acts. + +#### Signature: + +```typescript +class Locator { + static race(locators: Locator[]): Locator; +} +``` + +## Parameters + +| Parameter | Type | Description | +| --------- | ------------------------------------- | ----------- | +| locators | [Locator](./puppeteer.locator.md)\[\] | | + +**Returns:** + +[Locator](./puppeteer.locator.md) diff --git a/docs/api/puppeteer.locator.scroll.md b/docs/api/puppeteer.locator.scroll.md index 1781c7543b2..c343bf21f8c 100644 --- a/docs/api/puppeteer.locator.scroll.md +++ b/docs/api/puppeteer.locator.scroll.md @@ -8,7 +8,7 @@ sidebar_label: Locator.scroll ```typescript class Locator { - scroll(scrollOptions?: { + abstract scroll(scrollOptions?: { scrollTop?: number; scrollLeft?: number; signal?: AbortSignal; diff --git a/docs/api/puppeteer.locator.setensureelementisintheviewport.md b/docs/api/puppeteer.locator.setensureelementisintheviewport.md index e83c5d37e70..37de38bdcb5 100644 --- a/docs/api/puppeteer.locator.setensureelementisintheviewport.md +++ b/docs/api/puppeteer.locator.setensureelementisintheviewport.md @@ -8,7 +8,7 @@ sidebar_label: Locator.setEnsureElementIsInTheViewport ```typescript class Locator { - setEnsureElementIsInTheViewport(value: boolean): this; + abstract setEnsureElementIsInTheViewport(value: boolean): this; } ``` diff --git a/docs/api/puppeteer.locator.settimeout.md b/docs/api/puppeteer.locator.settimeout.md index 33099941c66..da7508c93df 100644 --- a/docs/api/puppeteer.locator.settimeout.md +++ b/docs/api/puppeteer.locator.settimeout.md @@ -8,7 +8,7 @@ sidebar_label: Locator.setTimeout ```typescript class Locator { - setTimeout(timeout: number): this; + abstract setTimeout(timeout: number): this; } ``` diff --git a/docs/api/puppeteer.locator.setvisibility.md b/docs/api/puppeteer.locator.setvisibility.md index 7d9430df823..1cab9ec8a28 100644 --- a/docs/api/puppeteer.locator.setvisibility.md +++ b/docs/api/puppeteer.locator.setvisibility.md @@ -8,7 +8,7 @@ sidebar_label: Locator.setVisibility ```typescript class Locator { - setVisibility(visibility: VisibilityOption): this; + abstract setVisibility(visibility: VisibilityOption): this; } ``` diff --git a/docs/api/puppeteer.locator.setwaitforenabled.md b/docs/api/puppeteer.locator.setwaitforenabled.md index a076de776f6..3b097f4023c 100644 --- a/docs/api/puppeteer.locator.setwaitforenabled.md +++ b/docs/api/puppeteer.locator.setwaitforenabled.md @@ -8,7 +8,7 @@ sidebar_label: Locator.setWaitForEnabled ```typescript class Locator { - setWaitForEnabled(value: boolean): this; + abstract setWaitForEnabled(value: boolean): this; } ``` diff --git a/docs/api/puppeteer.locator.setwaitforstableboundingbox.md b/docs/api/puppeteer.locator.setwaitforstableboundingbox.md index 88c85fe7c1f..f76323a1799 100644 --- a/docs/api/puppeteer.locator.setwaitforstableboundingbox.md +++ b/docs/api/puppeteer.locator.setwaitforstableboundingbox.md @@ -8,7 +8,7 @@ sidebar_label: Locator.setWaitForStableBoundingBox ```typescript class Locator { - setWaitForStableBoundingBox(value: boolean): this; + abstract setWaitForStableBoundingBox(value: boolean): this; } ``` diff --git a/packages/puppeteer-core/src/api/Locator.ts b/packages/puppeteer-core/src/api/Locator.ts index 2f762e7d886..40b011b98ac 100644 --- a/packages/puppeteer-core/src/api/Locator.ts +++ b/packages/puppeteer-core/src/api/Locator.ts @@ -116,30 +116,24 @@ export interface LocatorEventObject { * * @public */ -export class Locator extends EventEmitter { +export abstract class Locator extends EventEmitter { /** * @internal */ static create(pageOrFrame: Page | Frame, selector: string): Locator { - return new Locator(pageOrFrame, selector).setTimeout( + return new LocatorImpl(pageOrFrame, selector).setTimeout( 'getDefaultTimeout' in pageOrFrame ? pageOrFrame.getDefaultTimeout() : pageOrFrame.page().getDefaultTimeout() ); } - #pageOrFrame: Page | Frame; - #selector: string; - #visibility: VisibilityOption = 'visible'; - #timeout = 30_000; - #ensureElementIsInTheViewport = true; - #waitForEnabled = true; - #waitForStableBoundingBox = true; - - private constructor(pageOrFrame: Page | Frame, selector: string) { - super(); - this.#pageOrFrame = pageOrFrame; - this.#selector = selector; + /** + * Creates a race between multiple locators but ensures that only a single one + * acts. + */ + static race(locators: Locator[]): Locator { + return new RaceLocatorImpl(locators); } override on( @@ -163,6 +157,60 @@ export class Locator extends EventEmitter { return super.off(eventName, handler); } + abstract setVisibility(visibility: VisibilityOption): this; + + abstract setTimeout(timeout: number): this; + + abstract setEnsureElementIsInTheViewport(value: boolean): this; + + abstract setWaitForEnabled(value: boolean): this; + + abstract setWaitForStableBoundingBox(value: boolean): this; + + abstract click( + clickOptions?: ClickOptions & { + signal?: AbortSignal; + } + ): Promise; + + /** + * Fills out the input identified by the locator using the provided value. The + * type of the input is determined at runtime and the appropriate fill-out + * method is chosen based on the type. contenteditable, selector, inputs are + * supported. + */ + abstract fill( + value: string, + fillOptions?: {signal?: AbortSignal} + ): Promise; + + abstract hover(hoverOptions?: {signal?: AbortSignal}): Promise; + + abstract scroll(scrollOptions?: { + scrollTop?: number; + scrollLeft?: number; + signal?: AbortSignal; + }): Promise; +} + +/** + * @internal + */ +export class LocatorImpl extends Locator { + #pageOrFrame: Page | Frame; + #selector: string; + #visibility: VisibilityOption = 'visible'; + #timeout = 30_000; + #ensureElementIsInTheViewport = true; + #waitForEnabled = true; + #waitForStableBoundingBox = true; + + constructor(pageOrFrame: Page | Frame, selector: string) { + super(); + this.#pageOrFrame = pageOrFrame; + this.#selector = selector; + } + setVisibility(visibility: VisibilityOption): this { this.#visibility = visibility; return this; @@ -211,6 +259,7 @@ export class Locator extends EventEmitter { () => { controller?.abort(); isActive = false; + clearTimeout(timeoutId); }, {once: true} ); @@ -593,3 +642,162 @@ export class Locator extends EventEmitter { ); } } + +/** + * @internal + */ +class RaceLocatorImpl extends Locator { + #locators: Locator[]; + + constructor(locators: Locator[]) { + super(); + this.#locators = locators; + } + + override setVisibility(visibility: VisibilityOption): this { + for (const locator of this.#locators) { + locator.setVisibility(visibility); + } + return this; + } + + override setTimeout(timeout: number): this { + for (const locator of this.#locators) { + locator.setTimeout(timeout); + } + return this; + } + + override setEnsureElementIsInTheViewport(value: boolean): this { + for (const locator of this.#locators) { + locator.setEnsureElementIsInTheViewport(value); + } + return this; + } + + override setWaitForEnabled(value: boolean): this { + for (const locator of this.#locators) { + locator.setWaitForEnabled(value); + } + return this; + } + + override setWaitForStableBoundingBox(value: boolean): this { + for (const locator of this.#locators) { + locator.setWaitForStableBoundingBox(value); + } + return this; + } + + async #runRace( + action: (el: Locator, abortSignal: AbortSignal) => Promise, + options: { + signal?: AbortSignal; + } + ) { + const abortControllers = new WeakMap(); + + // Abort all locators if the user-provided signal aborts. + options.signal?.addEventListener('abort', () => { + for (const locator of this.#locators) { + abortControllers.get(locator)?.abort(); + } + }); + + const handleLocatorAction = (locator: Locator): (() => void) => { + return () => { + // When one locator is ready to act, we will abort other locators. + for (const other of this.#locators) { + if (other !== locator) { + abortControllers.get(other)?.abort(); + } + } + this.emit(LocatorEmittedEvents.Action); + }; + }; + + const createAbortController = (locator: Locator): AbortController => { + const abortController = new AbortController(); + abortControllers.set(locator, abortController); + return abortController; + }; + + await Promise.allSettled( + this.#locators.map(locator => { + return action( + locator.on(LocatorEmittedEvents.Action, handleLocatorAction(locator)), + createAbortController(locator).signal + ); + }) + ); + + options.signal?.throwIfAborted(); + } + + override async click( + clickOptions?: ClickOptions & { + signal?: AbortSignal; + } + ): Promise { + return await this.#runRace( + (locator, abortSignal) => { + return locator.click({ + ...clickOptions, + signal: abortSignal, + }); + }, + { + signal: clickOptions?.signal, + } + ); + } + + override async fill( + value: string, + fillOptions?: {signal?: AbortSignal} + ): Promise { + return await this.#runRace( + (locator, abortSignal) => { + return locator.fill(value, { + ...fillOptions, + signal: abortSignal, + }); + }, + { + signal: fillOptions?.signal, + } + ); + } + + override async hover(hoverOptions?: {signal?: AbortSignal}): Promise { + return await this.#runRace( + (locator, abortSignal) => { + return locator.hover({ + ...hoverOptions, + signal: abortSignal, + }); + }, + { + signal: hoverOptions?.signal, + } + ); + } + + override async scroll(scrollOptions?: { + scrollTop?: number; + scrollLeft?: number; + signal?: AbortSignal; + }): Promise { + return await this.#runRace( + (locator, abortSignal) => { + return locator.hover({ + ...scrollOptions, + signal: abortSignal, + }); + }, + { + signal: scrollOptions?.signal, + } + ); + } +} diff --git a/test/TestExpectations.json b/test/TestExpectations.json index 04d6c916fca..48197702ec8 100644 --- a/test/TestExpectations.json +++ b/test/TestExpectations.json @@ -395,6 +395,12 @@ "parameters": ["chrome"], "expectations": ["SKIP"] }, + { + "testIdPattern": "[navigation.spec] navigation \"after each\" hook for \"should work with both domcontentloaded and load\"", + "platforms": ["darwin", "linux", "win32"], + "parameters": ["webDriverBiDi"], + "expectations": ["FAIL"] + }, { "testIdPattern": "[navigation.spec] navigation Page.goto should fail when navigating to bad SSL", "platforms": ["darwin", "linux", "win32"], @@ -2482,11 +2488,5 @@ "platforms": ["darwin", "linux", "win32"], "parameters": ["cdp", "chrome", "headless"], "expectations": ["FAIL", "PASS"] - }, - { - "testIdPattern": "[navigation.spec] navigation \"after each\" hook for \"should work with both domcontentloaded and load\"", - "platforms": ["darwin", "linux", "win32"], - "parameters": ["webDriverBiDi"], - "expectations": ["FAIL"] } ] diff --git a/test/src/locator.spec.ts b/test/src/locator.spec.ts index 7e6154cc226..698339156f0 100644 --- a/test/src/locator.spec.ts +++ b/test/src/locator.spec.ts @@ -16,7 +16,10 @@ import expect from 'expect'; import {TimeoutError} from 'puppeteer-core'; -import {LocatorEmittedEvents} from 'puppeteer-core/internal/api/Locator.js'; +import { + Locator, + LocatorEmittedEvents, +} from 'puppeteer-core/internal/api/Locator.js'; import sinon from 'sinon'; import { @@ -444,4 +447,53 @@ describe('Locator', function () { ).toBe(true); }); }); + + describe('Locator.race', () => { + it('races multiple locators', async () => { + const {page} = getTestState(); + + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + + `); + await page.evaluate(() => { + // @ts-expect-error different context. + window.count = 0; + }); + await Locator.race([ + page.locator('button'), + page.locator('button'), + ]).click(); + const count = await page.evaluate(() => { + // @ts-expect-error different context. + return globalThis.count; + }); + expect(count).toBe(1); + }); + + it('can be aborted', async () => { + const {page} = getTestState(); + const clock = sinon.useFakeTimers(); + try { + await page.setViewport({width: 500, height: 500}); + await page.setContent(` + + `); + const abortController = new AbortController(); + const result = Locator.race([ + page.locator('button'), + page.locator('button'), + ]) + .setTimeout(5000) + .click({ + signal: abortController.signal, + }); + clock.tick(2000); + abortController.abort(); + await expect(result).rejects.toThrow(/aborted/); + } finally { + clock.restore(); + } + }); + }); });