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 ```typescript
class Locator { class Locator {
click( abstract click(
clickOptions?: ClickOptions & { clickOptions?: ClickOptions & {
signal?: AbortSignal; signal?: AbortSignal;
} }

View File

@ -10,7 +10,7 @@ Fills out the input identified by the locator using the provided value. The type
```typescript ```typescript
class Locator { class Locator {
fill( abstract fill(
value: string, value: string,
fillOptions?: { fillOptions?: {
signal?: AbortSignal; signal?: AbortSignal;

View File

@ -8,7 +8,7 @@ sidebar_label: Locator.hover
```typescript ```typescript
class Locator { 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: #### Signature:
```typescript ```typescript
export declare class Locator extends EventEmitter export declare abstract class Locator extends EventEmitter
``` ```
**Extends:** [EventEmitter](./puppeteer.eventemitter.md) **Extends:** [EventEmitter](./puppeteer.eventemitter.md)
@ -17,13 +17,14 @@ export declare class Locator extends EventEmitter
## Methods ## Methods
| Method | Modifiers | Description | | Method | Modifiers | Description |
| ------------------------------------------------------------------------------------------------ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------------------------------------------------------ | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [click(clickOptions)](./puppeteer.locator.click.md) | | | | [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. | | [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) | | | | [hover(hoverOptions)](./puppeteer.locator.hover.md) | | |
| [off(eventName, handler)](./puppeteer.locator.off.md) | | | | [off(eventName, handler)](./puppeteer.locator.off.md) | | |
| [on(eventName, handler)](./puppeteer.locator.on.md) | | | | [on(eventName, handler)](./puppeteer.locator.on.md) | | |
| [once(eventName, handler)](./puppeteer.locator.once.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) | | | | [scroll(scrollOptions)](./puppeteer.locator.scroll.md) | | |
| [setEnsureElementIsInTheViewport(value)](./puppeteer.locator.setensureelementisintheviewport.md) | | | | [setEnsureElementIsInTheViewport(value)](./puppeteer.locator.setensureelementisintheviewport.md) | | |
| [setTimeout(timeout)](./puppeteer.locator.settimeout.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 ```typescript
class Locator { class Locator {
scroll(scrollOptions?: { abstract scroll(scrollOptions?: {
scrollTop?: number; scrollTop?: number;
scrollLeft?: number; scrollLeft?: number;
signal?: AbortSignal; signal?: AbortSignal;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -116,30 +116,24 @@ export interface LocatorEventObject {
* *
* @public * @public
*/ */
export class Locator extends EventEmitter { export abstract class Locator extends EventEmitter {
/** /**
* @internal * @internal
*/ */
static create(pageOrFrame: Page | Frame, selector: string): Locator { static create(pageOrFrame: Page | Frame, selector: string): Locator {
return new Locator(pageOrFrame, selector).setTimeout( return new LocatorImpl(pageOrFrame, selector).setTimeout(
'getDefaultTimeout' in pageOrFrame 'getDefaultTimeout' in pageOrFrame
? pageOrFrame.getDefaultTimeout() ? pageOrFrame.getDefaultTimeout()
: pageOrFrame.page().getDefaultTimeout() : pageOrFrame.page().getDefaultTimeout()
); );
} }
#pageOrFrame: Page | Frame; /**
#selector: string; * Creates a race between multiple locators but ensures that only a single one
#visibility: VisibilityOption = 'visible'; * acts.
#timeout = 30_000; */
#ensureElementIsInTheViewport = true; static race(locators: Locator[]): Locator {
#waitForEnabled = true; return new RaceLocatorImpl(locators);
#waitForStableBoundingBox = true;
private constructor(pageOrFrame: Page | Frame, selector: string) {
super();
this.#pageOrFrame = pageOrFrame;
this.#selector = selector;
} }
override on<K extends keyof LocatorEventObject>( override on<K extends keyof LocatorEventObject>(
@ -163,6 +157,60 @@ export class Locator extends EventEmitter {
return super.off(eventName, handler); 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 { setVisibility(visibility: VisibilityOption): this {
this.#visibility = visibility; this.#visibility = visibility;
return this; return this;
@ -211,6 +259,7 @@ export class Locator extends EventEmitter {
() => { () => {
controller?.abort(); controller?.abort();
isActive = false; isActive = false;
clearTimeout(timeoutId);
}, },
{once: true} {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"], "parameters": ["chrome"],
"expectations": ["SKIP"] "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", "testIdPattern": "[navigation.spec] navigation Page.goto should fail when navigating to bad SSL",
"platforms": ["darwin", "linux", "win32"], "platforms": ["darwin", "linux", "win32"],
@ -2482,11 +2488,5 @@
"platforms": ["darwin", "linux", "win32"], "platforms": ["darwin", "linux", "win32"],
"parameters": ["cdp", "chrome", "headless"], "parameters": ["cdp", "chrome", "headless"],
"expectations": ["FAIL", "PASS"] "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 expect from 'expect';
import {TimeoutError} from 'puppeteer-core'; 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 sinon from 'sinon';
import { import {
@ -444,4 +447,53 @@ describe('Locator', function () {
).toBe(true); ).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();
}
});
});
}); });