Skip to content

Commit b11d7f1

Browse files
authored
feat(input): retry when hit target check fails, prepare for page pause (#2020)
1 parent 6c94f60 commit b11d7f1

File tree

8 files changed

+137
-82
lines changed

8 files changed

+137
-82
lines changed

src/chromium/crPage.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,10 @@ export class CRPage implements PageDelegate {
258258
return this._sessionForHandle(handle)._scrollRectIntoViewIfNeeded(handle, rect);
259259
}
260260

261+
async setActivityPaused(paused: boolean): Promise<void> {
262+
await this._forAllFrameSessions(frame => frame._setActivityPaused(paused));
263+
}
264+
261265
async getContentQuads(handle: dom.ElementHandle): Promise<types.Quad[] | null> {
262266
return this._sessionForHandle(handle)._getContentQuads(handle);
263267
}
@@ -795,6 +799,9 @@ class FrameSession {
795799
});
796800
}
797801

802+
async _setActivityPaused(paused: boolean): Promise<void> {
803+
}
804+
798805
async _getContentQuads(handle: dom.ElementHandle): Promise<types.Quad[] | null> {
799806
const result = await this._client.send('DOM.getContentQuads', {
800807
objectId: toRemoteObject(handle).objectId

src/dom.ts

Lines changed: 73 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
163163
}
164164

165165
async _scrollRectIntoViewIfNeeded(rect?: types.Rect): Promise<void> {
166-
this._page._log(inputLog, 'scrolling into view if needed...');
167166
await this._page._delegate.scrollRectIntoViewIfNeeded(this, rect);
168-
this._page._log(inputLog, '...done');
169167
}
170168

171169
async scrollIntoViewIfNeeded() {
@@ -229,44 +227,78 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
229227
return point;
230228
}
231229

232-
async _performPointerAction(action: (point: types.Point) => Promise<void>, options: PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise<void> {
230+
async _retryPointerAction(action: (point: types.Point) => Promise<void>, options: PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise<void> {
233231
const deadline = this._page._timeoutSettings.computeDeadline(options);
234-
const { force = false } = (options || {});
235-
if (!force)
232+
while (!helper.isPastDeadline(deadline)) {
233+
const result = await this._performPointerAction(action, deadline, options);
234+
if (result === 'done')
235+
return;
236+
}
237+
throw new TimeoutError(`waiting for element to receive pointer events failed: timeout exceeded`);
238+
}
239+
240+
async _performPointerAction(action: (point: types.Point) => Promise<void>, deadline: number, options: PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise<'done' | 'retry'> {
241+
const { force = false, position } = options;
242+
if (!force && !(options as any).__testHookSkipStablePosition)
236243
await this._waitForDisplayedAtStablePosition(deadline);
237-
const position = options ? options.position : undefined;
238-
await this._scrollRectIntoViewIfNeeded(position ? { x: position.x, y: position.y, width: 0, height: 0 } : undefined);
239-
const point = position ? await this._offsetPoint(position) : await this._clickablePoint();
240-
point.x = (point.x * 100 | 0) / 100;
241-
point.y = (point.y * 100 | 0) / 100;
242-
await this._page.mouse.move(point.x, point.y); // Force any hover effects before waiting for hit target.
243-
if (options && (options as any).__testHookBeforeWaitForHitTarget)
244-
await (options as any).__testHookBeforeWaitForHitTarget();
245-
if (!force)
246-
await this._waitForHitTargetAt(point, deadline);
247244

248-
await this._page._frameManager.waitForSignalsCreatedBy(async () => {
249-
let restoreModifiers: input.Modifier[] | undefined;
250-
if (options && options.modifiers)
251-
restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers);
252-
this._page._log(inputLog, 'performing input action...');
253-
await action(point);
254-
this._page._log(inputLog, '...done');
255-
if (restoreModifiers)
256-
await this._page.keyboard._ensureModifiers(restoreModifiers);
257-
}, deadline, options, true);
245+
let paused = false;
246+
try {
247+
await this._page._delegate.setActivityPaused(true);
248+
paused = true;
249+
250+
// Scroll into view and calculate the point again while paused just in case something has moved.
251+
this._page._log(inputLog, 'scrolling into view if needed...');
252+
await this._scrollRectIntoViewIfNeeded(position ? { x: position.x, y: position.y, width: 0, height: 0 } : undefined);
253+
this._page._log(inputLog, '...done scrolling');
254+
const point = roundPoint(position ? await this._offsetPoint(position) : await this._clickablePoint());
255+
256+
if (!force) {
257+
if ((options as any).__testHookBeforeHitTarget)
258+
await (options as any).__testHookBeforeHitTarget();
259+
this._page._log(inputLog, `checking that element receives pointer events at (${point.x},${point.y})...`);
260+
const matchesHitTarget = await this._checkHitTargetAt(point);
261+
if (!matchesHitTarget) {
262+
this._page._log(inputLog, '...element does not receive pointer events, retrying input action');
263+
await this._page._delegate.setActivityPaused(false);
264+
paused = false;
265+
return 'retry';
266+
}
267+
this._page._log(inputLog, `...element does receive pointer events, continuing input action`);
268+
}
269+
270+
await this._page._frameManager.waitForSignalsCreatedBy(async () => {
271+
let restoreModifiers: input.Modifier[] | undefined;
272+
if (options && options.modifiers)
273+
restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers);
274+
this._page._log(inputLog, 'performing input action...');
275+
await action(point);
276+
this._page._log(inputLog, '...input action done');
277+
this._page._log(inputLog, 'waiting for navigations to finish...');
278+
await this._page._delegate.setActivityPaused(false);
279+
paused = false;
280+
if (restoreModifiers)
281+
await this._page.keyboard._ensureModifiers(restoreModifiers);
282+
}, deadline, options, true);
283+
this._page._log(inputLog, '...navigations have finished');
284+
285+
return 'done';
286+
} finally {
287+
if (paused)
288+
await this._page._delegate.setActivityPaused(false);
289+
}
258290
}
259291

260292
hover(options?: PointerActionOptions & types.PointerActionWaitOptions): Promise<void> {
261-
return this._performPointerAction(point => this._page.mouse.move(point.x, point.y), options);
293+
return this._retryPointerAction(point => this._page.mouse.move(point.x, point.y), options);
262294
}
263295

264296
click(options?: ClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<void> {
265-
return this._performPointerAction(point => this._page.mouse.click(point.x, point.y, options), options);
297+
return this._retryPointerAction(point => this._page.mouse.click(point.x, point.y, options), options);
266298
}
267299

268300
dblclick(options?: MultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<void> {
269-
return this._performPointerAction(point => this._page.mouse.dblclick(point.x, point.y, options), options);
301+
return this._retryPointerAction(point => this._page.mouse.dblclick(point.x, point.y, options), options);
270302
}
271303

272304
async selectOption(values: string | ElementHandle | types.SelectOption | string[] | ElementHandle[] | types.SelectOption[], options?: types.NavigatingActionWaitOptions): Promise<string[]> {
@@ -429,11 +461,10 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
429461
const timeoutMessage = 'element to be displayed and not moving';
430462
const injectedResult = await helper.waitWithDeadline(stablePromise, timeoutMessage, deadline);
431463
handleInjectedResult(injectedResult, timeoutMessage);
432-
this._page._log(inputLog, '...done');
464+
this._page._log(inputLog, '...element is displayed and does not move');
433465
}
434466

435-
async _waitForHitTargetAt(point: types.Point, deadline: number): Promise<void> {
436-
this._page._log(inputLog, `waiting for element to receive pointer events at (${point.x},${point.y}) ...`);
467+
async _checkHitTargetAt(point: types.Point): Promise<boolean> {
437468
const frame = await this.ownerFrame();
438469
if (frame && frame.parentFrame()) {
439470
const element = await frame.frameElement();
@@ -443,13 +474,10 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
443474
// Translate from viewport coordinates to frame coordinates.
444475
point = { x: point.x - box.x, y: point.y - box.y };
445476
}
446-
const hitTargetPromise = this._evaluateInUtility(({ injected, node }, { timeout, point }) => {
447-
return injected.waitForHitTargetAt(node, timeout, point);
448-
}, { timeout: helper.timeUntilDeadline(deadline), point });
449-
const timeoutMessage = 'element to receive pointer events';
450-
const injectedResult = await helper.waitWithDeadline(hitTargetPromise, timeoutMessage, deadline);
451-
handleInjectedResult(injectedResult, timeoutMessage);
452-
this._page._log(inputLog, '...done');
477+
const injectedResult = await this._evaluateInUtility(({ injected, node }, { point }) => {
478+
return injected.checkHitTargetAt(node, point);
479+
}, { point });
480+
return handleInjectedResult(injectedResult, '');
453481
}
454482
}
455483

@@ -470,3 +498,10 @@ function handleInjectedResult<T = undefined>(injectedResult: InjectedResult<T>,
470498
throw new Error(injectedResult.error);
471499
return injectedResult.value as T;
472500
}
501+
502+
function roundPoint(point: types.Point): types.Point {
503+
return {
504+
x: (point.x * 100 | 0) / 100,
505+
y: (point.y * 100 | 0) / 100,
506+
};
507+
}

src/firefox/ffPage.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,9 @@ export class FFPage implements PageDelegate {
428428
});
429429
}
430430

431+
async setActivityPaused(paused: boolean): Promise<void> {
432+
}
433+
431434
async getContentQuads(handle: dom.ElementHandle): Promise<types.Quad[] | null> {
432435
const result = await this._session.send('Page.getContentQuads', {
433436
frameId: handle._context.frame._id,

src/injected/injected.ts

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -324,30 +324,16 @@ export class Injected {
324324
return { status: result === 'notconnected' ? 'notconnected' : (result ? 'success' : 'timeout') };
325325
}
326326

327-
async waitForHitTargetAt(node: Node, timeout: number, point: types.Point): Promise<InjectedResult> {
328-
const targetElement = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
329-
let element = targetElement;
327+
checkHitTargetAt(node: Node, point: types.Point): InjectedResult<boolean> {
328+
let element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
330329
while (element && window.getComputedStyle(element).pointerEvents === 'none')
331330
element = element.parentElement;
332-
if (!element)
333-
return { status: 'notconnected' };
334-
const result = await this.poll('raf', timeout, (): 'notconnected' | 'moved' | boolean => {
335-
if (!element!.isConnected)
336-
return 'notconnected';
337-
const clientRect = targetElement!.getBoundingClientRect();
338-
if (clientRect.left > point.x || clientRect.left + clientRect.width < point.x ||
339-
clientRect.top > point.y || clientRect.top + clientRect.height < point.y)
340-
return 'moved';
341-
let hitElement = this._deepElementFromPoint(document, point.x, point.y);
342-
while (hitElement && hitElement !== element)
343-
hitElement = this._parentElementOrShadowHost(hitElement);
344-
return hitElement === element;
345-
});
346-
if (result === 'notconnected')
331+
if (!element || !element.isConnected)
347332
return { status: 'notconnected' };
348-
if (result === 'moved')
349-
return { status: 'error', error: 'Element has moved during the action' };
350-
return { status: result ? 'success' : 'timeout' };
333+
let hitElement = this._deepElementFromPoint(document, point.x, point.y);
334+
while (hitElement && hitElement !== element)
335+
hitElement = this._parentElementOrShadowHost(hitElement);
336+
return { status: 'success', value: hitElement === element };
351337
}
352338

353339
dispatchEvent(node: Node, type: string, eventInit: Object) {

src/page.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export interface PageDelegate {
6969
getBoundingBox(handle: dom.ElementHandle): Promise<types.Rect | null>;
7070
getFrameElement(frame: frames.Frame): Promise<dom.ElementHandle>;
7171
scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise<void>;
72+
setActivityPaused(paused: boolean): Promise<void>;
7273

7374
getAccessibilityTree(needle?: dom.ElementHandle): Promise<{tree: accessibility.AXNode, needle: accessibility.AXNode | null}>;
7475
pdf?: (options?: types.PDFOptions) => Promise<Buffer>;

src/webkit/wkPage.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,9 @@ export class WKPage implements PageDelegate {
731731
});
732732
}
733733

734+
async setActivityPaused(paused: boolean): Promise<void> {
735+
}
736+
734737
async getContentQuads(handle: dom.ElementHandle): Promise<types.Quad[] | null> {
735738
const result = await this._session.send('DOM.getContentQuads', {
736739
objectId: toRemoteObject(handle).objectId!

test/assets/input/animating-button.html

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,29 @@
1414
button.addEventListener('click', () => window.clicked = true);
1515
document.body.appendChild(button);
1616
}
17+
1718
function stopButton(remove) {
1819
const button = document.querySelector('button');
1920
button.style.marginLeft = button.getBoundingClientRect().left + 'px';
2021
button.style.animation = '';
2122
if (remove)
2223
button.remove();
2324
}
24-
function startJumping() {
25+
26+
let x = 0;
27+
function jump() {
28+
x += 300;
2529
const button = document.querySelector('button');
26-
let x = 0;
30+
button.style.marginLeft = x + 'px';
31+
}
32+
33+
function startJumping() {
34+
x = 0;
2735
const moveIt = () => {
28-
x += 300;
29-
button.style.marginLeft = x + 'px';
36+
jump();
3037
requestAnimationFrame(moveIt);
3138
};
39+
setInterval(jump, 0);
3240
moveIt();
3341
}
3442
</script>

test/click.spec.js

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,6 @@ describe('Page.click', function() {
221221
'mouseover',
222222
'mouseenter',
223223
'mousemove',
224-
'mousemove',
225224
'mousedown',
226225
'mouseup',
227226
'click',
@@ -571,37 +570,50 @@ describe('Page.click', function() {
571570
expect(clicked).toBe(true);
572571
expect(await page.evaluate(() => window.clicked)).toBe(true);
573572
});
574-
it('should fail when element moves during hit testing', async({page, server}) => {
573+
it('should retry when element jumps during hit testing', async({page, server}) => {
575574
await page.goto(server.PREFIX + '/input/animating-button.html');
576575
await page.evaluate(() => addButton());
577576
let clicked = false;
578577
const handle = await page.$('button');
579-
const __testHookBeforeWaitForHitTarget = () => page.evaluate(() => startJumping());
580-
const promise = handle.click({ timeout: 0, __testHookBeforeWaitForHitTarget }).then(() => clicked = true).catch(e => e);
578+
const __testHookBeforeHitTarget = () => page.evaluate(() => { if (window.x === 0) jump(); });
579+
const promise = handle.click({ timeout: 0, __testHookBeforeHitTarget }).then(() => clicked = true);
581580
expect(clicked).toBe(false);
582581
expect(await page.evaluate(() => window.clicked)).toBe(undefined);
583582
await page.evaluate(() => stopButton());
583+
await promise;
584+
expect(clicked).toBe(true);
585+
expect(await page.evaluate(() => window.clicked)).toBe(true);
586+
});
587+
it('should fail when element jumps during hit testing', async({page, server}) => {
588+
await page.goto(server.PREFIX + '/input/animating-button.html');
589+
await page.evaluate(() => addButton());
590+
await page.evaluate(() => stopButton());
591+
let clicked = false;
592+
const handle = await page.$('button');
593+
const __testHookBeforeHitTarget = () => page.evaluate(() => jump());
594+
const promise = handle.click({ timeout: 1000, __testHookBeforeHitTarget, __testHookSkipStablePosition: true }).then(() => clicked = true).catch(e => e);
584595
const error = await promise;
585596
expect(clicked).toBe(false);
586-
expect(error.message).toBe('Element has moved during the action');
587597
expect(await page.evaluate(() => window.clicked)).toBe(undefined);
588-
});
589-
it('should fail when element is blocked on hover', async({page, server}) => {
590-
await page.setContent(`<style>
591-
container { display: block; position: relative; width: 200px; height: 50px; }
592-
div, button { position: absolute; left: 0; top: 0; bottom: 0; right: 0; }
593-
div { pointer-events: none; }
594-
container:hover div { pointer-events: auto; background: red; }
595-
</style>
596-
<container>
597-
<button onclick="window.clicked=true">Click me</button>
598-
<div></div>
599-
</container>`);
600-
const error = await page.click('button', { timeout: 3000 }).catch(e => e);
601598
expect(error.message).toBe('waiting for element to receive pointer events failed: timeout exceeded');
599+
});
600+
it.fail(CHROMIUM || WEBKIT || FFOX)('should work when element jumps uncontrollably', async({page, server}) => {
601+
// This test requires pausing the page.
602+
await page.goto(server.PREFIX + '/input/animating-button.html');
603+
await page.evaluate(() => addButton());
604+
await page.evaluate(() => stopButton());
605+
const handle = await page.$('button');
606+
await page.evaluate(() => startJumping());
607+
let clicked = false;
608+
const promise = handle.click({ timeout: 1000, __testHookSkipStablePosition: true }).then(() => clicked = true);
609+
expect(clicked).toBe(false);
602610
expect(await page.evaluate(() => window.clicked)).toBe(undefined);
611+
await promise;
612+
expect(clicked).toBe(true);
613+
expect(await page.evaluate(() => window.clicked)).toBe(true);
603614
});
604-
it('should wait while element is blocked on hover', async({page, server}) => {
615+
it.fail(CHROMIUM || WEBKIT || FFOX)('should wait while element is blocked on hover', async({page, server}) => {
616+
// This test requires pausing the page.
605617
await page.setContent(`<style>
606618
@keyframes move-out { from { marign-left: 0; } to { margin-left: 150px; } }
607619
container { display: block; position: relative; width: 200px; height: 50px; }

0 commit comments

Comments
 (0)