Skip to content

Commit 9413351

Browse files
authored
feat(click): waitForInteractable option, defaults to true (#934)
1 parent 39c580a commit 9413351

File tree

8 files changed

+236
-63
lines changed

8 files changed

+236
-63
lines changed

docs/api.md

Lines changed: 33 additions & 7 deletions
Large diffs are not rendered by default.

src/dom.ts

Lines changed: 85 additions & 38 deletions
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,52 @@ 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+
return injected.poll('raf', undefined, timeout, () => {
520+
const clientRect = element.getBoundingClientRect();
521+
const rect = { x: clientRect.top, y: clientRect.left, width: clientRect.width, height: clientRect.height };
522+
const isStable = lastRect && rect.x === lastRect.x && rect.y === lastRect.y && rect.width === lastRect.width && rect.height === lastRect.height;
523+
lastRect = rect;
524+
return isStable;
525+
});
526+
}, await context._injected(), this, options.timeout || 0);
527+
await helper.waitWithTimeout(stablePromise, 'element to stop moving', options.timeout || 0);
528+
}
529+
530+
async _waitForHitTargetAt(point: types.Point, options: types.TimeoutOptions = {}): Promise<void> {
531+
const frame = await this.ownerFrame();
532+
if (frame && frame.parentFrame()) {
533+
const element = await frame.frameElement();
534+
const box = await element.boundingBox();
535+
if (!box)
536+
throw new Error('Element is not attached to the DOM');
537+
// Translate from viewport coordinates to frame coordinates.
538+
point = { x: point.x - box.x, y: point.y - box.y };
539+
}
540+
const context = await this._context.frame._utilityContext();
541+
const hitTargetPromise = context.evaluate((injected: Injected, node: Node, timeout: number, point: types.Point) => {
542+
const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
543+
if (!element)
544+
throw new Error('Element is not attached to the DOM');
545+
return injected.poll('raf', undefined, timeout, () => {
546+
let hitElement = injected.utils.deepElementFromPoint(document, point.x, point.y);
547+
while (hitElement && hitElement !== element)
548+
hitElement = injected.utils.parentElementOrShadowHost(hitElement);
549+
return hitElement === element;
550+
});
551+
}, await context._injected(), this, options.timeout || 0, point);
552+
await helper.waitWithTimeout(hitTargetPromise, 'element to receive mouse events', options.timeout || 0);
553+
}
500554
}
501555

502556
function normalizeSelector(selector: string): string {
@@ -514,51 +568,44 @@ function normalizeSelector(selector: string): string {
514568

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

517-
export function waitForFunctionTask(selector: string | undefined, pageFunction: Function | string, options: types.WaitForFunctionOptions, ...args: any[]) {
518-
const { polling = 'raf' } = options;
571+
function assertPolling(polling: types.Polling) {
519572
if (helper.isString(polling))
520573
assert(polling === 'raf' || polling === 'mutation', 'Unknown polling option: ' + polling);
521574
else if (helper.isNumber(polling))
522575
assert(polling > 0, 'Cannot poll with non-positive interval: ' + polling);
523576
else
524577
throw new Error('Unknown polling options: ' + polling);
578+
}
579+
580+
export function waitForFunctionTask(selector: string | undefined, pageFunction: Function | string, options: types.WaitForFunctionOptions, ...args: any[]): Task {
581+
const { polling = 'raf' } = options;
582+
assertPolling(polling);
525583
const predicateBody = helper.isString(pageFunction) ? 'return (' + pageFunction + ')' : 'return (' + pageFunction + ')(...args)';
526584
if (selector !== undefined)
527585
selector = normalizeSelector(selector);
528586

529587
return async (context: FrameExecutionContext) => context.evaluateHandle((injected: Injected, selector: string | undefined, predicateBody: string, polling: types.Polling, timeout: number, ...args) => {
530588
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 {
589+
return injected.poll(polling, selector, timeout, (element: Element | undefined): any => {
538590
if (selector === undefined)
539591
return innerPredicate(...args);
540592
return innerPredicate(element, ...args);
541-
}
593+
});
542594
}, await context._injected(), selector, predicateBody, polling, options.timeout || 0, ...args);
543595
}
544596

545597
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-
};
598+
selector = normalizeSelector(selector);
599+
return async (context: FrameExecutionContext) => context.evaluateHandle((injected: Injected, selector: string, visibility: types.Visibility, timeout: number) => {
600+
const polling = visibility === 'any' ? 'mutation' : 'raf';
601+
return injected.poll(polling, selector, timeout, (element: Element | undefined): Element | boolean => {
602+
if (!element)
603+
return visibility === 'hidden';
604+
if (visibility === 'any')
605+
return element;
606+
return injected.isVisible(element) === (visibility === 'visible') ? element : false;
607+
});
608+
}, await context._injected(), selector, visibility, timeout);
562609
}
563610

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

src/frames.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -780,19 +780,19 @@ export class Frame {
780780
return result!;
781781
}
782782

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

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

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

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

833-
async check(selector: string, options?: WaitForOptions) {
833+
async check(selector: string, options?: WaitForOptions & dom.WaitForInteractableOptions) {
834834
const handle = await this._optionallyWaitForSelectorInUtilityContext(selector, options);
835-
await handle.check();
835+
await handle.check(options);
836836
await handle.dispose();
837837
}
838838

839-
async uncheck(selector: string, options?: WaitForOptions) {
839+
async uncheck(selector: string, options?: WaitForOptions & dom.WaitForInteractableOptions) {
840840
const handle = await this._optionallyWaitForSelectorInUtilityContext(selector, options);
841-
await handle.uncheck();
841+
await handle.uncheck(options);
842842
await handle.dispose();
843843
}
844844

src/injected/injected.ts

Lines changed: 11 additions & 3 deletions
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

Lines changed: 6 additions & 6 deletions
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

Lines changed: 2 additions & 0 deletions
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)