feature(click): option to wait for 'stationary' or 'hittarget'

This commit is contained in:
Dmitry Gozman 2019-12-16 12:24:25 -08:00
parent a8f9c627f1
commit 817836c836
8 changed files with 254 additions and 34 deletions

View file

@ -12,6 +12,7 @@ import * as zsSelectorEngineSource from './generated/zsSelectorEngineSource';
import { assert, helper, debugError } from './helper';
import Injected from './injected/injected';
import { Page } from './page';
import { TimeoutError } from './errors';
type ScopedSelector = types.Selector & { scope?: ElementHandle };
type ResolvedSelector = { scope?: ElementHandle, selector: string, visibility: types.Visibility, disposeScope?: boolean };
@ -279,8 +280,66 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
return { point, scrollX, scrollY };
}
async _performPointerAction(action: (point: types.Point) => Promise<void>, options?: input.PointerActionOptions): Promise<void> {
private async _waitForStationary(options: types.TimeoutOptions) {
const { timeout = this._page._timeoutSettings.timeout() } = options;
const success = await helper.waitWithTimeout(this.evaluate((node: Node, injected: Injected, timeout: number) => {
let elementState;
return injected.pollRaf(() => {
if (!node.isConnected || node.nodeType !== Node.ELEMENT_NODE)
return false;
const element = node as Element;
const rect = element.getBoundingClientRect();
let computedOpacity = 1;
for (let parent: Element | undefined = element; parent; parent = injected.utils.parentElementOrShadowHost(parent))
computedOpacity *= +getComputedStyle(parent).opacity;
const newState = {
x: rect.top,
y: rect.left,
width: rect.width,
height: rect.height,
computedOpacity,
iteration: elementState ? elementState.iteration + 1 : 1,
};
if (elementState &&
elementState.iteration >= 2 &&
newState.x === elementState.x &&
newState.y === elementState.y &&
newState.width === elementState.width &&
newState.height === elementState.height &&
newState.computedOpacity === elementState.computedOpacity) {
return true;
}
elementState = newState;
return false;
}, timeout);
}, await this._context._injected(), timeout), 'stationary', timeout);
if (!success)
throw new TimeoutError(`waiting for stationary failed: timeout ${timeout}ms exceeded`);
}
private async _waitToBecomeHitTargetAt(point: types.Point, options: types.TimeoutOptions) {
const { timeout = this._page._timeoutSettings.timeout() } = options;
const success = await helper.waitWithTimeout(this.evaluate((node: Node, injected: Injected, timeout: number, point: types.Point) => {
return injected.pollRaf(() => {
for (let hitElement = injected.utils.deepElementFromPoint(document, point.x, point.y);
hitElement;
hitElement = injected.utils.parentElementOrShadowHost(hitElement)) {
if (hitElement === node)
return true;
}
return false;
}, timeout);
}, await this._context._injected(), timeout, point), 'hit target', timeout);
if (!success)
throw new TimeoutError(`waiting for hit target failed: timeout ${timeout}ms exceeded`);
}
async _performPointerAction(action: (point: types.Point) => Promise<void>, options?: input.PointerActionOptions & types.WaitForOptions<'stationary' | 'hittarget'>): Promise<void> {
if (options && types.multipleContains(options.waitFor, 'stationary'))
await this._waitForStationary(options);
const point = await this._ensurePointerActionPoint(options ? options.relativePoint : undefined);
if (options && types.multipleContains(options.waitFor, 'hittarget'))
await this._waitToBecomeHitTargetAt(point, options);
let restoreModifiers: input.Modifier[] | undefined;
if (options && options.modifiers)
restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers);
@ -289,19 +348,19 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
await this._page.keyboard._ensureModifiers(restoreModifiers);
}
hover(options?: input.PointerActionOptions): Promise<void> {
hover(options?: input.PointerActionOptions & types.WaitForOptions<'stationary' | 'hittarget'>): Promise<void> {
return this._performPointerAction(point => this._page.mouse.move(point.x, point.y), options);
}
click(options?: input.ClickOptions): Promise<void> {
click(options?: input.ClickOptions & types.WaitForOptions<'stationary' | 'hittarget'>): Promise<void> {
return this._performPointerAction(point => this._page.mouse.click(point.x, point.y, options), options);
}
dblclick(options?: input.MultiClickOptions): Promise<void> {
dblclick(options?: input.MultiClickOptions & types.WaitForOptions<'stationary' | 'hittarget'>): Promise<void> {
return this._performPointerAction(point => this._page.mouse.dblclick(point.x, point.y, options), options);
}
tripleclick(options?: input.MultiClickOptions): Promise<void> {
tripleclick(options?: input.MultiClickOptions & types.WaitForOptions<'stationary' | 'hittarget'>): Promise<void> {
return this._performPointerAction(point => this._page.mouse.tripleclick(point.x, point.y, options), options);
}
@ -350,7 +409,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
throw new Error(errorMessage);
}
async type(text: string, options: { delay: (number | undefined); } | undefined) {
async type(text: string, options?: { delay?: number }) {
await this.focus();
await this._page.keyboard.type(text, options);
}

View file

@ -466,7 +466,7 @@ export class FrameManager extends EventEmitter implements PageDelegate {
}
}
export function normalizeWaitUntil(waitUntil: frames.LifecycleEvent | frames.LifecycleEvent[]): frames.LifecycleEvent[] {
export function normalizeWaitUntil(waitUntil: types.Multiple<frames.LifecycleEvent>): frames.LifecycleEvent[] {
if (!Array.isArray(waitUntil))
waitUntil = [waitUntil];
for (const condition of waitUntil) {

View file

@ -39,7 +39,7 @@ type ContextData = {
export type NavigateOptions = {
timeout?: number,
waitUntil?: LifecycleEvent | LifecycleEvent[],
waitUntil?: types.Multiple<LifecycleEvent>,
};
export type GotoOptions = NavigateOptions & {
@ -48,8 +48,6 @@ export type GotoOptions = NavigateOptions & {
export type LifecycleEvent = 'load' | 'domcontentloaded';
export type WaitForOptions = types.TimeoutOptions & { waitFor?: boolean };
export class Frame {
_id: string;
readonly _firedLifecycleEvents: Set<LifecycleEvent>;
@ -308,43 +306,43 @@ export class Frame {
return result;
}
async click(selector: string | types.Selector, options?: WaitForOptions & ClickOptions) {
const handle = await this._optionallyWaitForInUtilityContext(selector, options);
await handle.click(options);
async click(selector: string | types.Selector, options?: ClickOptions & types.WaitForOptions<'selector' | 'stationary' | 'hittarget'>) {
const handle = await this._optionallyWaitForInUtilityContext(selector, types.toWaitFor(options));
await handle.click(types.toWaitFor(options));
await handle.dispose();
}
async dblclick(selector: string | types.Selector, options?: WaitForOptions & MultiClickOptions) {
const handle = await this._optionallyWaitForInUtilityContext(selector, options);
await handle.dblclick(options);
async dblclick(selector: string | types.Selector, options?: MultiClickOptions & types.WaitForOptions<'selector' | 'stationary' | 'hittarget'>) {
const handle = await this._optionallyWaitForInUtilityContext(selector, types.toWaitFor(options));
await handle.dblclick(types.toWaitFor(options));
await handle.dispose();
}
async tripleclick(selector: string | types.Selector, options?: WaitForOptions & MultiClickOptions) {
const handle = await this._optionallyWaitForInUtilityContext(selector, options);
await handle.tripleclick(options);
async tripleclick(selector: string | types.Selector, options?: MultiClickOptions & types.WaitForOptions<'selector' | 'stationary' | 'hittarget'>) {
const handle = await this._optionallyWaitForInUtilityContext(selector, types.toWaitFor(options));
await handle.tripleclick(types.toWaitFor(options));
await handle.dispose();
}
async fill(selector: string | types.Selector, value: string, options?: WaitForOptions) {
async fill(selector: string | types.Selector, value: string, options?: types.WaitForOptions<'selector'>) {
const handle = await this._optionallyWaitForInUtilityContext(selector, options);
await handle.fill(value);
await handle.dispose();
}
async focus(selector: string | types.Selector, options?: WaitForOptions) {
async focus(selector: string | types.Selector, options?: types.WaitForOptions<'selector'>) {
const handle = await this._optionallyWaitForInUtilityContext(selector, options);
await handle.focus();
await handle.dispose();
}
async hover(selector: string | types.Selector, options?: WaitForOptions & PointerActionOptions) {
const handle = await this._optionallyWaitForInUtilityContext(selector, options);
await handle.hover(options);
async hover(selector: string | types.Selector, options?: PointerActionOptions & types.WaitForOptions<'selector' | 'stationary' | 'hittarget'>) {
const handle = await this._optionallyWaitForInUtilityContext(selector, types.toWaitFor(options));
await handle.hover(types.toWaitFor(options));
await handle.dispose();
}
async select(selector: string | types.Selector, value: string | dom.ElementHandle | SelectOption | string[] | dom.ElementHandle[] | SelectOption[] | undefined, options?: WaitForOptions): Promise<string[]> {
async select(selector: string | types.Selector, value: types.Multiple<string | dom.ElementHandle | SelectOption> | undefined, options?: types.WaitForOptions<'selector'>): Promise<string[]> {
const handle = await this._optionallyWaitForInUtilityContext(selector, options);
const toDispose: Promise<dom.ElementHandle>[] = [];
const values = value === undefined ? [] : value instanceof Array ? value : [value];
@ -363,10 +361,8 @@ export class Frame {
return result;
}
async type(selector: string | types.Selector, text: string, options: WaitForOptions & { delay: (number | undefined); } | undefined) {
const context = await this._utilityContext();
const handle = await context._$(types.clearSelector(selector));
assert(handle, 'No node found for selector: ' + types.selectorToString(selector));
async type(selector: string | types.Selector, text: string, options?: { delay?: number } & types.WaitForOptions<'selector'>) {
const handle = await this._optionallyWaitForInUtilityContext(selector, options);
await handle.type(text, options);
await handle.dispose();
}
@ -381,9 +377,9 @@ export class Frame {
return Promise.reject(new Error('Unsupported target type: ' + (typeof selectorOrFunctionOrTimeout)));
}
private async _optionallyWaitForInUtilityContext(selector: string | types.Selector,options: WaitForOptions): Promise<dom.ElementHandle | null> {
private async _optionallyWaitForInUtilityContext(selector: string | types.Selector, options?: types.WaitForOptions<'selector'>): Promise<dom.ElementHandle | null> {
let handle: dom.ElementHandle | null;
if (options && options.waitFor) {
if (options && types.multipleContains(options.waitFor, 'selector')) {
handle = await this._waitForSelectorInUtilityContext(selector, options);
} else {
const context = await this._utilityContext();
@ -616,7 +612,7 @@ export class LifecycleWatcher {
private _targetUrl?: string;
private _expectedDocumentId?: string;
constructor(frame: Frame, waitUntil: LifecycleEvent | LifecycleEvent[], timeout: number) {
constructor(frame: Frame, waitUntil: types.Multiple<LifecycleEvent>, timeout: number) {
if (Array.isArray(waitUntil))
waitUntil = waitUntil.slice();
else if (typeof waitUntil === 'string')

View file

@ -133,7 +133,7 @@ export class Keyboard {
await this._raw.sendText(text);
}
async type(text: string, options: { delay: (number | undefined); } | undefined) {
async type(text: string, options?: { delay?: number }) {
const delay = (options && options.delay) || null;
for (const char of text) {
if (keyboardLayout.keyDefinitions[char]) {

View file

@ -72,3 +72,13 @@ export type Viewport = {
isLandscape?: boolean;
hasTouch?: boolean;
};
export type Multiple<T> = T | T[];
export function multipleContains<T>(multiple: Multiple<T> | undefined, t: T): boolean {
return multiple === t || (Array.isArray(multiple) && multiple.includes(t));
}
export type WaitForOptions<T> = TimeoutOptions & { waitFor?: Multiple<T> };
export function toWaitFor<A, B>(o: WaitForOptions<A>): A extends B ? WaitForOptions<B> : never {
return o as any as (A extends B ? WaitForOptions<B> : never);
}

View file

@ -2,10 +2,43 @@
<html>
<head>
<title>Button test</title>
<style>
body {
position: relative;
}
div.glasspane {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 100;
pointer-events: none;
}
div.glasspane.capture {
pointer-events: all;
}
@keyframes leftright {
from {
margin-left: 0;
}
to {
margin-left: 400px;
}
}
button.moving {
animation: 1s infinite alternate leftright;
}
</style>
</head>
<body>
<script src="mouse-helper.js"></script>
<button>Click target</button>
<div class=glasspane></div>
<script>
window.result = 'Was not clicked';
window.offsetX = undefined;
@ -17,6 +50,16 @@
offsetY = e.offsetY;
shiftKey = e.shiftKey;
}, false);
function toggleGlassPane() {
document.querySelector('div.glasspane').classList.toggle('capture');
}
if (window.location.search.includes('glasspane'))
toggleGlassPane();
function toggleMoving() {
document.querySelector('button').classList.toggle('moving');
}
if (window.location.search.includes('moving'))
toggleMoving();
</script>
</body>
</html>

View file

@ -354,5 +354,112 @@ module.exports.addTests = function({testRunner, expect, playwright, FFOX, CHROME
await page.click('button');
expect(await page.evaluate('window.clicked')).toBe(true);
});
describe('wait for option', () => {
it('should wait for selector', async({page, server}) => {
let clicked = false;
const clickPromise = page.click('button', { waitFor: 'selector' }).then(() => clicked = true);
expect(clicked).toBe(false);
await page.goto(server.EMPTY_PAGE);
expect(clicked).toBe(false);
await page.goto(server.PREFIX + '/input/button.html');
await clickPromise;
expect(await page.evaluate(() => result)).toBe('Clicked');
});
it('should wait for hit target', async({page, server}) => {
await page.goto(server.PREFIX + '/input/button.html?glasspane');
let clicked = false;
const clickPromise = page.click('button', { waitFor: 'hittarget' }).then(() => clicked = true);
expect(clicked).toBe(false);
for (let i = 0; i < 5; i++)
await page.evaluate(() => 'dummy');
expect(clicked).toBe(false);
await page.evaluate(() => toggleGlassPane());
await clickPromise;
expect(await page.evaluate(() => result)).toBe('Clicked');
});
it('should wait for stationary', async({page, server}) => {
await page.goto(server.PREFIX + '/input/button.html?moving');
let clicked = false;
const clickPromise = page.click('button', { waitFor: 'stationary' }).then(() => clicked = true);
expect(clicked).toBe(false);
for (let i = 0; i < 5; i++)
await page.evaluate(() => new Promise(f => requestAnimationFrame(f)));
expect(clicked).toBe(false);
await page.evaluate(() => toggleMoving());
await clickPromise;
expect(await page.evaluate(() => result)).toBe('Clicked');
});
it('should wait for selector and hit target', async({page, server}) => {
let clicked = false;
const clickPromise = page.click('button', { waitFor: ['hittarget', 'selector'] }).then(() => clicked = true);
expect(clicked).toBe(false);
await page.goto(server.EMPTY_PAGE);
expect(clicked).toBe(false);
await page.goto(server.PREFIX + '/input/button.html?glasspane');
expect(clicked).toBe(false);
for (let i = 0; i < 5; i++)
await page.evaluate(() => 'dummy');
expect(clicked).toBe(false);
await page.evaluate(() => toggleGlassPane());
await clickPromise;
expect(await page.evaluate(() => result)).toBe('Clicked');
});
it('should wait for selector and stationary', async({page, server}) => {
let clicked = false;
const clickPromise = page.click('button', { waitFor: ['stationary', 'selector'] }).then(() => clicked = true);
expect(clicked).toBe(false);
await page.goto(server.EMPTY_PAGE);
expect(clicked).toBe(false);
await page.goto(server.PREFIX + '/input/button.html?moving');
expect(clicked).toBe(false);
for (let i = 0; i < 5; i++)
await page.evaluate(() => new Promise(f => requestAnimationFrame(f)));
expect(clicked).toBe(false);
await page.evaluate(() => toggleMoving());
await clickPromise;
expect(await page.evaluate(() => result)).toBe('Clicked');
});
it('should wait for stationary and hit target', async({page, server}) => {
await page.goto(server.PREFIX + '/input/button.html?moving&glasspane');
let clicked = false;
const clickPromise = page.click('button', { waitFor: ['stationary', 'hittarget'] }).then(() => clicked = true);
expect(clicked).toBe(false);
for (let i = 0; i < 5; i++)
await page.evaluate(() => new Promise(f => requestAnimationFrame(f)));
expect(clicked).toBe(false);
await page.evaluate(() => toggleMoving());
expect(clicked).toBe(false);
for (let i = 0; i < 5; i++)
await page.evaluate(() => new Promise(f => requestAnimationFrame(f)));
expect(clicked).toBe(false);
await page.evaluate(() => toggleGlassPane());
await clickPromise;
expect(await page.evaluate(() => result)).toBe('Clicked');
});
it('should wait for hit target and stationary', async({page, server}) => {
await page.goto(server.PREFIX + '/input/button.html?moving&glasspane');
let clicked = false;
const clickPromise = page.click('button', { waitFor: ['stationary', 'hittarget'] }).then(() => clicked = true);
expect(clicked).toBe(false);
for (let i = 0; i < 5; i++)
await page.evaluate(() => new Promise(f => requestAnimationFrame(f)));
expect(clicked).toBe(false);
await page.evaluate(() => toggleGlassPane());
expect(clicked).toBe(false);
for (let i = 0; i < 5; i++)
await page.evaluate(() => new Promise(f => requestAnimationFrame(f)));
expect(clicked).toBe(false);
await page.evaluate(() => toggleMoving());
await clickPromise;
expect(await page.evaluate(() => result)).toBe('Clicked');
});
});
});
};

View file

@ -226,6 +226,11 @@ class TestServer {
serveFile(request, response, pathName) {
if (pathName === '/')
pathName = '/index.html';
try {
const url = new URL('http://localhost' + pathName);
pathName = url.pathname;
} catch (e) {
}
const filePath = path.join(this._dirPath, pathName.substring(1));
if (this._cachedPathPrefix !== null && filePath.startsWith(this._cachedPathPrefix)) {