feat: implement Locator.race (#10337)

This commit is contained in:
Alex Rudenko 2023-06-07 12:45:02 +02:00 committed by GitHub
parent dde569b97d
commit 9c35e9ab1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 331 additions and 45 deletions

View File

@ -8,7 +8,7 @@ sidebar_label: Locator.click
```typescript
class Locator {
click(
abstract click(
clickOptions?: ClickOptions & {
signal?: AbortSignal;
}

View File

@ -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;

View File

@ -8,7 +8,7 @@ sidebar_label: Locator.hover
```typescript
class Locator {
hover(hoverOptions?: {signal?: AbortSignal}): Promise<void>;
abstract hover(hoverOptions?: {signal?: AbortSignal}): Promise<void>;
}
```

View File

@ -9,7 +9,7 @@ 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)
@ -17,13 +17,14 @@ export declare class Locator extends EventEmitter
## 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) | | |
| [race(locators)](./puppeteer.locator.race.md) | <code>static</code> | 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) | | |

View File

@ -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)

View File

@ -8,7 +8,7 @@ sidebar_label: Locator.scroll
```typescript
class Locator {
scroll(scrollOptions?: {
abstract scroll(scrollOptions?: {
scrollTop?: number;
scrollLeft?: number;
signal?: AbortSignal;

View File

@ -8,7 +8,7 @@ sidebar_label: Locator.setEnsureElementIsInTheViewport
```typescript
class Locator {
setEnsureElementIsInTheViewport(value: boolean): this;
abstract setEnsureElementIsInTheViewport(value: boolean): this;
}
```

View File

@ -8,7 +8,7 @@ sidebar_label: Locator.setTimeout
```typescript
class Locator {
setTimeout(timeout: number): this;
abstract setTimeout(timeout: number): this;
}
```

View File

@ -8,7 +8,7 @@ sidebar_label: Locator.setVisibility
```typescript
class Locator {
setVisibility(visibility: VisibilityOption): this;
abstract setVisibility(visibility: VisibilityOption): this;
}
```

View File

@ -8,7 +8,7 @@ sidebar_label: Locator.setWaitForEnabled
```typescript
class Locator {
setWaitForEnabled(value: boolean): this;
abstract setWaitForEnabled(value: boolean): this;
}
```

View File

@ -8,7 +8,7 @@ sidebar_label: Locator.setWaitForStableBoundingBox
```typescript
class Locator {
setWaitForStableBoundingBox(value: boolean): this;
abstract setWaitForStableBoundingBox(value: boolean): this;
}
```

View File

@ -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<K extends keyof LocatorEventObject>(
@ -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<void>;
/**
* 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<void>;
abstract hover(hoverOptions?: {signal?: AbortSignal}): Promise<void>;
abstract scroll(scrollOptions?: {
scrollTop?: number;
scrollLeft?: number;
signal?: AbortSignal;
}): Promise<void>;
}
/**
* @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<void>,
options: {
signal?: AbortSignal;
}
) {
const abortControllers = new WeakMap<Locator, AbortController>();
// 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<void> {
return await this.#runRace(
(locator, abortSignal) => {
return locator.click({
...clickOptions,
signal: abortSignal,
});
},
{
signal: clickOptions?.signal,
}
);
}
override async fill(
value: string,
fillOptions?: {signal?: AbortSignal}
): Promise<void> {
return await this.#runRace(
(locator, abortSignal) => {
return locator.fill(value, {
...fillOptions,
signal: abortSignal,
});
},
{
signal: fillOptions?.signal,
}
);
}
override async hover(hoverOptions?: {signal?: AbortSignal}): Promise<void> {
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<void> {
return await this.#runRace(
(locator, abortSignal) => {
return locator.hover({
...scrollOptions,
signal: abortSignal,
});
},
{
signal: scrollOptions?.signal,
}
);
}
}

View File

@ -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"]
}
]

View File

@ -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(`
<button onclick="window.count++;">test</button>
`);
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(`
<button style="display: none;" onclick="this.innerText = 'clicked';">test</button>
`);
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();
}
});
});
});