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 { assert, helper, debugError } from './helper';
import Injected from './injected/injected'; import Injected from './injected/injected';
import { Page } from './page'; import { Page } from './page';
import { TimeoutError } from './errors';
type ScopedSelector = types.Selector & { scope?: ElementHandle }; type ScopedSelector = types.Selector & { scope?: ElementHandle };
type ResolvedSelector = { scope?: ElementHandle, selector: string, visibility: types.Visibility, disposeScope?: boolean }; 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 }; 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); 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; let restoreModifiers: input.Modifier[] | undefined;
if (options && options.modifiers) if (options && options.modifiers)
restoreModifiers = await this._page.keyboard._ensureModifiers(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); 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); 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); 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); 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); 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); 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.focus();
await this._page.keyboard.type(text, options); 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)) if (!Array.isArray(waitUntil))
waitUntil = [waitUntil]; waitUntil = [waitUntil];
for (const condition of waitUntil) { for (const condition of waitUntil) {

View file

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

View file

@ -133,7 +133,7 @@ export class Keyboard {
await this._raw.sendText(text); 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; const delay = (options && options.delay) || null;
for (const char of text) { for (const char of text) {
if (keyboardLayout.keyDefinitions[char]) { if (keyboardLayout.keyDefinitions[char]) {

View file

@ -72,3 +72,13 @@ export type Viewport = {
isLandscape?: boolean; isLandscape?: boolean;
hasTouch?: 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> <html>
<head> <head>
<title>Button test</title> <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> </head>
<body> <body>
<script src="mouse-helper.js"></script> <script src="mouse-helper.js"></script>
<button>Click target</button> <button>Click target</button>
<div class=glasspane></div>
<script> <script>
window.result = 'Was not clicked'; window.result = 'Was not clicked';
window.offsetX = undefined; window.offsetX = undefined;
@ -17,6 +50,16 @@
offsetY = e.offsetY; offsetY = e.offsetY;
shiftKey = e.shiftKey; shiftKey = e.shiftKey;
}, false); }, 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> </script>
</body> </body>
</html> </html>

View file

@ -354,5 +354,112 @@ module.exports.addTests = function({testRunner, expect, playwright, FFOX, CHROME
await page.click('button'); await page.click('button');
expect(await page.evaluate('window.clicked')).toBe(true); 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) { serveFile(request, response, pathName) {
if (pathName === '/') if (pathName === '/')
pathName = '/index.html'; 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)); const filePath = path.join(this._dirPath, pathName.substring(1));
if (this._cachedPathPrefix !== null && filePath.startsWith(this._cachedPathPrefix)) { if (this._cachedPathPrefix !== null && filePath.startsWith(this._cachedPathPrefix)) {