chore: implement locators with a click (#10009)
Co-authored-by: jrandolf <101637635+jrandolf@users.noreply.github.com>
This commit is contained in:
parent
9758cae029
commit
9a1aff8a4c
360
packages/puppeteer-core/src/api/Locator.ts
Normal file
360
packages/puppeteer-core/src/api/Locator.ts
Normal 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,
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
@ -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`.
|
||||
|
@ -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
229
test/src/locator.spec.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user