chore: implement locators with a click (#10009)

Co-authored-by: jrandolf <101637635+jrandolf@users.noreply.github.com>
This commit is contained in:
Alex Rudenko 2023-05-05 11:11:16 +02:00 committed by GitHub
parent 9758cae029
commit 9a1aff8a4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 598 additions and 0 deletions

View File

@ -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<void>;
/**
* @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<K extends keyof LocatorEventObject>(
eventName: K,
handler: (event: LocatorEventObject[K]) => void
): Locator {
return super.on(eventName, handler) as Locator;
}
override once<K extends keyof LocatorEventObject>(
eventName: K,
handler: (event: LocatorEventObject[K]) => void
): Locator {
return super.once(eventName, handler) as Locator;
}
override off<K extends keyof LocatorEventObject>(
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<void> {
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<void> => {
// 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<void> => {
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<void> => {
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<void> => {
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<void>,
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<void> {
await this.#run(
async element => {
await element.click(clickOptions);
},
{
signal: clickOptions?.signal,
conditions: [
this.#ensureElementIsInTheViewport,
this.#waitForVisibility,
this.#waitForEnabled,
this.#waitForStableBoundingBox,
],
}
);
}
}

View File

@ -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`.

View File

@ -21,3 +21,4 @@ export * from './JSHandle.js';
export * from './ElementHandle.js';
export * from './HTTPResponse.js';
export * from './HTTPRequest.js';
export * from './Locator.js';

229
test/src/locator.spec.ts Normal file
View File

@ -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(`
<button onclick="this.innerText = 'clicked';">test</button>
`);
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(`
<button onclick="this.innerText = 'clicked';">test</button>
`);
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(`
<button style="margin-top: 600px;" onclick="this.innerText = 'clicked';">test</button>
`);
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(`
<button style="display: none;" onclick="this.innerText = 'clicked';">test</button>
`);
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(`
<button disabled onclick="this.innerText = 'clicked';">test</button>
`);
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(`
<button style="margin-top: 600px;" style="display: none;" disabled onclick="this.innerText = 'clicked';">test</button>
`);
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(`
<button style="display: none;" onclick="this.innerText = 'clicked';">test</button>
`);
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(`
<button style="display: none;" onclick="this.innerText = 'clicked';">test</button>
`);
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(`
<button style="display: none;" onclick="this.innerText = 'clicked';">test</button>
`);
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();
}
});
});
});