Skip to content

Commit 33824aa

Browse files
authored
feat(click): waitForInteractable option, defaults to true (#934) (#1052)
1 parent 9f1edad commit 33824aa

File tree

8 files changed

+242
-63
lines changed

8 files changed

+242
-63
lines changed

docs/api.md

+33-7
Large diffs are not rendered by default.

src/dom.ts

+91-38
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import { Page } from './page';
2525
import * as platform from './platform';
2626
import { Selectors } from './selectors';
2727

28+
export type WaitForInteractableOptions = types.TimeoutOptions & { waitForInteractable?: boolean };
29+
2830
export class FrameExecutionContext extends js.ExecutionContext {
2931
readonly frame: frames.Frame;
3032

@@ -230,10 +232,15 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
230232
return point;
231233
}
232234

233-
async _performPointerAction(action: (point: types.Point) => Promise<void>, options?: input.PointerActionOptions): Promise<void> {
235+
async _performPointerAction(action: (point: types.Point) => Promise<void>, options?: input.PointerActionOptions & WaitForInteractableOptions): Promise<void> {
236+
const { waitForInteractable = true } = (options || {});
237+
if (waitForInteractable)
238+
await this._waitForStablePosition(options);
234239
const relativePoint = options ? options.relativePoint : undefined;
235240
await this._scrollRectIntoViewIfNeeded(relativePoint ? { x: relativePoint.x, y: relativePoint.y, width: 0, height: 0 } : undefined);
236241
const point = relativePoint ? await this._relativePoint(relativePoint) : await this._clickablePoint();
242+
if (waitForInteractable)
243+
await this._waitForHitTargetAt(point, options);
237244
let restoreModifiers: input.Modifier[] | undefined;
238245
if (options && options.modifiers)
239246
restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers);
@@ -242,19 +249,19 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
242249
await this._page.keyboard._ensureModifiers(restoreModifiers);
243250
}
244251

245-
hover(options?: input.PointerActionOptions): Promise<void> {
252+
hover(options?: input.PointerActionOptions & WaitForInteractableOptions): Promise<void> {
246253
return this._performPointerAction(point => this._page.mouse.move(point.x, point.y), options);
247254
}
248255

249-
click(options?: input.ClickOptions): Promise<void> {
256+
click(options?: input.ClickOptions & WaitForInteractableOptions): Promise<void> {
250257
return this._performPointerAction(point => this._page.mouse.click(point.x, point.y, options), options);
251258
}
252259

253-
dblclick(options?: input.MultiClickOptions): Promise<void> {
260+
dblclick(options?: input.MultiClickOptions & WaitForInteractableOptions): Promise<void> {
254261
return this._performPointerAction(point => this._page.mouse.dblclick(point.x, point.y, options), options);
255262
}
256263

257-
tripleclick(options?: input.MultiClickOptions): Promise<void> {
264+
tripleclick(options?: input.MultiClickOptions & WaitForInteractableOptions): Promise<void> {
258265
return this._performPointerAction(point => this._page.mouse.tripleclick(point.x, point.y, options), options);
259266
}
260267

@@ -402,19 +409,20 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
402409
await this._page.keyboard.type(text, options);
403410
}
404411

405-
async press(key: string, options: { delay?: number; text?: string; } | undefined) {
412+
async press(key: string, options?: { delay?: number, text?: string }) {
406413
await this.focus();
407414
await this._page.keyboard.press(key, options);
408415
}
409-
async check() {
410-
await this._setChecked(true);
416+
417+
async check(options?: WaitForInteractableOptions) {
418+
await this._setChecked(true, options);
411419
}
412420

413-
async uncheck() {
414-
await this._setChecked(false);
421+
async uncheck(options?: WaitForInteractableOptions) {
422+
await this._setChecked(false, options);
415423
}
416424

417-
private async _setChecked(state: boolean) {
425+
private async _setChecked(state: boolean, options: WaitForInteractableOptions = {}) {
418426
const isCheckboxChecked = async (): Promise<boolean> => {
419427
return this._evaluateInUtility((node: Node) => {
420428
if (node.nodeType !== Node.ELEMENT_NODE)
@@ -442,7 +450,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
442450

443451
if (await isCheckboxChecked() === state)
444452
return;
445-
await this.click();
453+
await this.click(options);
446454
if (await isCheckboxChecked() !== state)
447455
throw new Error('Unable to click checkbox');
448456
}
@@ -497,6 +505,58 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
497505
return visibleRatio;
498506
});
499507
}
508+
509+
async _waitForStablePosition(options: types.TimeoutOptions = {}): Promise<void> {
510+
const context = await this._context.frame._utilityContext();
511+
const stablePromise = context.evaluate((injected: Injected, node: Node, timeout: number) => {
512+
if (!node.isConnected)
513+
throw new Error('Element is not attached to the DOM');
514+
const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
515+
if (!element)
516+
throw new Error('Element is not attached to the DOM');
517+
518+
let lastRect: types.Rect | undefined;
519+
let counter = 0;
520+
return injected.poll('raf', undefined, timeout, () => {
521+
// First raf happens in the same animation frame as evaluation, so it does not produce
522+
// any client rect difference compared to synchronous call. We skip the synchronous call
523+
// and only force layout during actual rafs as a small optimisation.
524+
if (++counter === 1)
525+
return false;
526+
const clientRect = element.getBoundingClientRect();
527+
const rect = { x: clientRect.top, y: clientRect.left, width: clientRect.width, height: clientRect.height };
528+
const isStable = lastRect && rect.x === lastRect.x && rect.y === lastRect.y && rect.width === lastRect.width && rect.height === lastRect.height;
529+
lastRect = rect;
530+
return isStable;
531+
});
532+
}, await context._injected(), this, options.timeout || 0);
533+
await helper.waitWithTimeout(stablePromise, 'element to stop moving', options.timeout || 0);
534+
}
535+
536+
async _waitForHitTargetAt(point: types.Point, options: types.TimeoutOptions = {}): Promise<void> {
537+
const frame = await this.ownerFrame();
538+
if (frame && frame.parentFrame()) {
539+
const element = await frame.frameElement();
540+
const box = await element.boundingBox();
541+
if (!box)
542+
throw new Error('Element is not attached to the DOM');
543+
// Translate from viewport coordinates to frame coordinates.
544+
point = { x: point.x - box.x, y: point.y - box.y };
545+
}
546+
const context = await this._context.frame._utilityContext();
547+
const hitTargetPromise = context.evaluate((injected: Injected, node: Node, timeout: number, point: types.Point) => {
548+
const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
549+
if (!element)
550+
throw new Error('Element is not attached to the DOM');
551+
return injected.poll('raf', undefined, timeout, () => {
552+
let hitElement = injected.utils.deepElementFromPoint(document, point.x, point.y);
553+
while (hitElement && hitElement !== element)
554+
hitElement = injected.utils.parentElementOrShadowHost(hitElement);
555+
return hitElement === element;
556+
});
557+
}, await context._injected(), this, options.timeout || 0, point);
558+
await helper.waitWithTimeout(hitTargetPromise, 'element to receive mouse events', options.timeout || 0);
559+
}
500560
}
501561

502562
function normalizeSelector(selector: string): string {
@@ -514,51 +574,44 @@ function normalizeSelector(selector: string): string {
514574

515575
export type Task = (context: FrameExecutionContext) => Promise<js.JSHandle>;
516576

517-
export function waitForFunctionTask(selector: string | undefined, pageFunction: Function | string, options: types.WaitForFunctionOptions, ...args: any[]) {
518-
const { polling = 'raf' } = options;
577+
function assertPolling(polling: types.Polling) {
519578
if (helper.isString(polling))
520579
assert(polling === 'raf' || polling === 'mutation', 'Unknown polling option: ' + polling);
521580
else if (helper.isNumber(polling))
522581
assert(polling > 0, 'Cannot poll with non-positive interval: ' + polling);
523582
else
524583
throw new Error('Unknown polling options: ' + polling);
584+
}
585+
586+
export function waitForFunctionTask(selector: string | undefined, pageFunction: Function | string, options: types.WaitForFunctionOptions, ...args: any[]): Task {
587+
const { polling = 'raf' } = options;
588+
assertPolling(polling);
525589
const predicateBody = helper.isString(pageFunction) ? 'return (' + pageFunction + ')' : 'return (' + pageFunction + ')(...args)';
526590
if (selector !== undefined)
527591
selector = normalizeSelector(selector);
528592

529593
return async (context: FrameExecutionContext) => context.evaluateHandle((injected: Injected, selector: string | undefined, predicateBody: string, polling: types.Polling, timeout: number, ...args) => {
530594
const innerPredicate = new Function('...args', predicateBody);
531-
if (polling === 'raf')
532-
return injected.pollRaf(selector, predicate, timeout);
533-
if (polling === 'mutation')
534-
return injected.pollMutation(selector, predicate, timeout);
535-
return injected.pollInterval(selector, polling, predicate, timeout);
536-
537-
function predicate(element: Element | undefined): any {
595+
return injected.poll(polling, selector, timeout, (element: Element | undefined): any => {
538596
if (selector === undefined)
539597
return innerPredicate(...args);
540598
return innerPredicate(element, ...args);
541-
}
599+
});
542600
}, await context._injected(), selector, predicateBody, polling, options.timeout || 0, ...args);
543601
}
544602

545603
export function waitForSelectorTask(selector: string, visibility: types.Visibility, timeout: number): Task {
546-
return async (context: FrameExecutionContext) => {
547-
selector = normalizeSelector(selector);
548-
return context.evaluateHandle((injected: Injected, selector: string, visibility: types.Visibility, timeout: number) => {
549-
if (visibility !== 'any')
550-
return injected.pollRaf(selector, predicate, timeout);
551-
return injected.pollMutation(selector, predicate, timeout);
552-
553-
function predicate(element: Element | undefined): Element | boolean {
554-
if (!element)
555-
return visibility === 'hidden';
556-
if (visibility === 'any')
557-
return element;
558-
return injected.isVisible(element) === (visibility === 'visible') ? element : false;
559-
}
560-
}, await context._injected(), selector, visibility, timeout);
561-
};
604+
selector = normalizeSelector(selector);
605+
return async (context: FrameExecutionContext) => context.evaluateHandle((injected: Injected, selector: string, visibility: types.Visibility, timeout: number) => {
606+
const polling = visibility === 'any' ? 'mutation' : 'raf';
607+
return injected.poll(polling, selector, timeout, (element: Element | undefined): Element | boolean => {
608+
if (!element)
609+
return visibility === 'hidden';
610+
if (visibility === 'any')
611+
return element;
612+
return injected.isVisible(element) === (visibility === 'visible') ? element : false;
613+
});
614+
}, await context._injected(), selector, visibility, timeout);
562615
}
563616

564617
export const setFileInputFunction = async (element: HTMLInputElement, payloads: types.FilePayload[]) => {

src/frames.ts

+8-8
Original file line numberDiff line numberDiff line change
@@ -781,19 +781,19 @@ export class Frame {
781781
return result!;
782782
}
783783

784-
async click(selector: string, options?: WaitForOptions & ClickOptions) {
784+
async click(selector: string, options?: WaitForOptions & ClickOptions & dom.WaitForInteractableOptions) {
785785
const handle = await this._optionallyWaitForSelectorInUtilityContext(selector, options);
786786
await handle.click(options);
787787
await handle.dispose();
788788
}
789789

790-
async dblclick(selector: string, options?: WaitForOptions & MultiClickOptions) {
790+
async dblclick(selector: string, options?: WaitForOptions & MultiClickOptions & dom.WaitForInteractableOptions) {
791791
const handle = await this._optionallyWaitForSelectorInUtilityContext(selector, options);
792792
await handle.dblclick(options);
793793
await handle.dispose();
794794
}
795795

796-
async tripleclick(selector: string, options?: WaitForOptions & MultiClickOptions) {
796+
async tripleclick(selector: string, options?: WaitForOptions & MultiClickOptions & dom.WaitForInteractableOptions) {
797797
const handle = await this._optionallyWaitForSelectorInUtilityContext(selector, options);
798798
await handle.tripleclick(options);
799799
await handle.dispose();
@@ -811,7 +811,7 @@ export class Frame {
811811
await handle.dispose();
812812
}
813813

814-
async hover(selector: string, options?: WaitForOptions & PointerActionOptions) {
814+
async hover(selector: string, options?: WaitForOptions & PointerActionOptions & dom.WaitForInteractableOptions) {
815815
const handle = await this._optionallyWaitForSelectorInUtilityContext(selector, options);
816816
await handle.hover(options);
817817
await handle.dispose();
@@ -831,15 +831,15 @@ export class Frame {
831831
await handle.dispose();
832832
}
833833

834-
async check(selector: string, options?: WaitForOptions) {
834+
async check(selector: string, options?: WaitForOptions & dom.WaitForInteractableOptions) {
835835
const handle = await this._optionallyWaitForSelectorInUtilityContext(selector, options);
836-
await handle.check();
836+
await handle.check(options);
837837
await handle.dispose();
838838
}
839839

840-
async uncheck(selector: string, options?: WaitForOptions) {
840+
async uncheck(selector: string, options?: WaitForOptions & dom.WaitForInteractableOptions) {
841841
const handle = await this._optionallyWaitForSelectorInUtilityContext(selector, options);
842-
await handle.uncheck();
842+
await handle.uncheck(options);
843843
await handle.dispose();
844844
}
845845

src/injected/injected.ts

+11-3
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ class Injected {
145145
return !!(rect.top || rect.bottom || rect.width || rect.height);
146146
}
147147

148-
pollMutation(selector: string | undefined, predicate: Predicate, timeout: number): Promise<any> {
148+
private _pollMutation(selector: string | undefined, predicate: Predicate, timeout: number): Promise<any> {
149149
let timedOut = false;
150150
if (timeout)
151151
setTimeout(() => timedOut = true, timeout);
@@ -178,7 +178,7 @@ class Injected {
178178
return result;
179179
}
180180

181-
pollRaf(selector: string | undefined, predicate: Predicate, timeout: number): Promise<any> {
181+
private _pollRaf(selector: string | undefined, predicate: Predicate, timeout: number): Promise<any> {
182182
let timedOut = false;
183183
if (timeout)
184184
setTimeout(() => timedOut = true, timeout);
@@ -203,7 +203,7 @@ class Injected {
203203
return result;
204204
}
205205

206-
pollInterval(selector: string | undefined, pollInterval: number, predicate: Predicate, timeout: number): Promise<any> {
206+
private _pollInterval(selector: string | undefined, pollInterval: number, predicate: Predicate, timeout: number): Promise<any> {
207207
let timedOut = false;
208208
if (timeout)
209209
setTimeout(() => timedOut = true, timeout);
@@ -226,6 +226,14 @@ class Injected {
226226
onTimeout();
227227
return result;
228228
}
229+
230+
poll(polling: 'raf' | 'mutation' | number, selector: string | undefined, timeout: number, predicate: Predicate): Promise<any> {
231+
if (polling === 'raf')
232+
return this._pollRaf(selector, predicate, timeout);
233+
if (polling === 'mutation')
234+
return this._pollMutation(selector, predicate, timeout);
235+
return this._pollInterval(selector, polling, predicate, timeout);
236+
}
229237
}
230238

231239
export default Injected;

src/page.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -485,15 +485,15 @@ export class Page extends platform.EventEmitter {
485485
return this._closed;
486486
}
487487

488-
async click(selector: string, options?: frames.WaitForOptions & input.ClickOptions) {
488+
async click(selector: string, options?: frames.WaitForOptions & input.ClickOptions & dom.WaitForInteractableOptions) {
489489
return this.mainFrame().click(selector, options);
490490
}
491491

492-
async dblclick(selector: string, options?: frames.WaitForOptions & input.MultiClickOptions) {
492+
async dblclick(selector: string, options?: frames.WaitForOptions & input.MultiClickOptions & dom.WaitForInteractableOptions) {
493493
return this.mainFrame().dblclick(selector, options);
494494
}
495495

496-
async tripleclick(selector: string, options?: frames.WaitForOptions & input.MultiClickOptions) {
496+
async tripleclick(selector: string, options?: frames.WaitForOptions & input.MultiClickOptions & dom.WaitForInteractableOptions) {
497497
return this.mainFrame().tripleclick(selector, options);
498498
}
499499

@@ -505,7 +505,7 @@ export class Page extends platform.EventEmitter {
505505
return this.mainFrame().focus(selector, options);
506506
}
507507

508-
async hover(selector: string, options?: frames.WaitForOptions & input.PointerActionOptions) {
508+
async hover(selector: string, options?: frames.WaitForOptions & input.PointerActionOptions & dom.WaitForInteractableOptions) {
509509
return this.mainFrame().hover(selector, options);
510510
}
511511

@@ -517,11 +517,11 @@ export class Page extends platform.EventEmitter {
517517
return this.mainFrame().type(selector, text, options);
518518
}
519519

520-
async check(selector: string, options?: frames.WaitForOptions) {
520+
async check(selector: string, options?: frames.WaitForOptions & dom.WaitForInteractableOptions) {
521521
return this.mainFrame().check(selector, options);
522522
}
523523

524-
async uncheck(selector: string, options?: frames.WaitForOptions) {
524+
async uncheck(selector: string, options?: frames.WaitForOptions & dom.WaitForInteractableOptions) {
525525
return this.mainFrame().uncheck(selector, options);
526526
}
527527

test/assets/input/button.html

+2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
window.pageX = undefined;
1414
window.pageY = undefined;
1515
window.shiftKey = undefined;
16+
window.pageX = undefined;
17+
window.pageY = undefined;
1618
document.querySelector('button').addEventListener('click', e => {
1719
result = 'Clicked';
1820
offsetX = e.offsetX;

0 commit comments

Comments
 (0)