diff --git a/src/chromium/crPage.ts b/src/chromium/crPage.ts index e8b6414066..c78c62deeb 100644 --- a/src/chromium/crPage.ts +++ b/src/chromium/crPage.ts @@ -258,6 +258,10 @@ export class CRPage implements PageDelegate { return this._sessionForHandle(handle)._scrollRectIntoViewIfNeeded(handle, rect); } + async setActivityPaused(paused: boolean): Promise { + await this._forAllFrameSessions(frame => frame._setActivityPaused(paused)); + } + async getContentQuads(handle: dom.ElementHandle): Promise { return this._sessionForHandle(handle)._getContentQuads(handle); } @@ -795,6 +799,9 @@ class FrameSession { }); } + async _setActivityPaused(paused: boolean): Promise { + } + async _getContentQuads(handle: dom.ElementHandle): Promise { const result = await this._client.send('DOM.getContentQuads', { objectId: toRemoteObject(handle).objectId diff --git a/src/dom.ts b/src/dom.ts index 047ee129f5..4ac6361174 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -163,9 +163,7 @@ export class ElementHandle extends js.JSHandle { } async _scrollRectIntoViewIfNeeded(rect?: types.Rect): Promise { - this._page._log(inputLog, 'scrolling into view if needed...'); await this._page._delegate.scrollRectIntoViewIfNeeded(this, rect); - this._page._log(inputLog, '...done'); } async scrollIntoViewIfNeeded() { @@ -229,44 +227,78 @@ export class ElementHandle extends js.JSHandle { return point; } - async _performPointerAction(action: (point: types.Point) => Promise, options: PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise { + async _retryPointerAction(action: (point: types.Point) => Promise, options: PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise { const deadline = this._page._timeoutSettings.computeDeadline(options); - const { force = false } = (options || {}); - if (!force) - await this._waitForDisplayedAtStablePosition(deadline); - const position = options ? options.position : undefined; - await this._scrollRectIntoViewIfNeeded(position ? { x: position.x, y: position.y, width: 0, height: 0 } : undefined); - const point = position ? await this._offsetPoint(position) : await this._clickablePoint(); - point.x = (point.x * 100 | 0) / 100; - point.y = (point.y * 100 | 0) / 100; - await this._page.mouse.move(point.x, point.y); // Force any hover effects before waiting for hit target. - if (options && (options as any).__testHookBeforeWaitForHitTarget) - await (options as any).__testHookBeforeWaitForHitTarget(); - if (!force) - await this._waitForHitTargetAt(point, deadline); + while (!helper.isPastDeadline(deadline)) { + const result = await this._performPointerAction(action, deadline, options); + if (result === 'done') + return; + } + throw new TimeoutError(`waiting for element to receive pointer events failed: timeout exceeded`); + } - await this._page._frameManager.waitForSignalsCreatedBy(async () => { - let restoreModifiers: input.Modifier[] | undefined; - if (options && options.modifiers) - restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers); - this._page._log(inputLog, 'performing input action...'); - await action(point); - this._page._log(inputLog, '...done'); - if (restoreModifiers) - await this._page.keyboard._ensureModifiers(restoreModifiers); - }, deadline, options, true); + async _performPointerAction(action: (point: types.Point) => Promise, deadline: number, options: PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions = {}): Promise<'done' | 'retry'> { + const { force = false, position } = options; + if (!force && !(options as any).__testHookSkipStablePosition) + await this._waitForDisplayedAtStablePosition(deadline); + + let paused = false; + try { + await this._page._delegate.setActivityPaused(true); + paused = true; + + // Scroll into view and calculate the point again while paused just in case something has moved. + this._page._log(inputLog, 'scrolling into view if needed...'); + await this._scrollRectIntoViewIfNeeded(position ? { x: position.x, y: position.y, width: 0, height: 0 } : undefined); + this._page._log(inputLog, '...done scrolling'); + const point = roundPoint(position ? await this._offsetPoint(position) : await this._clickablePoint()); + + if (!force) { + if ((options as any).__testHookBeforeHitTarget) + await (options as any).__testHookBeforeHitTarget(); + this._page._log(inputLog, `checking that element receives pointer events at (${point.x},${point.y})...`); + const matchesHitTarget = await this._checkHitTargetAt(point); + if (!matchesHitTarget) { + this._page._log(inputLog, '...element does not receive pointer events, retrying input action'); + await this._page._delegate.setActivityPaused(false); + paused = false; + return 'retry'; + } + this._page._log(inputLog, `...element does receive pointer events, continuing input action`); + } + + await this._page._frameManager.waitForSignalsCreatedBy(async () => { + let restoreModifiers: input.Modifier[] | undefined; + if (options && options.modifiers) + restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers); + this._page._log(inputLog, 'performing input action...'); + await action(point); + this._page._log(inputLog, '...input action done'); + this._page._log(inputLog, 'waiting for navigations to finish...'); + await this._page._delegate.setActivityPaused(false); + paused = false; + if (restoreModifiers) + await this._page.keyboard._ensureModifiers(restoreModifiers); + }, deadline, options, true); + this._page._log(inputLog, '...navigations have finished'); + + return 'done'; + } finally { + if (paused) + await this._page._delegate.setActivityPaused(false); + } } hover(options?: PointerActionOptions & types.PointerActionWaitOptions): Promise { - return this._performPointerAction(point => this._page.mouse.move(point.x, point.y), options); + return this._retryPointerAction(point => this._page.mouse.move(point.x, point.y), options); } click(options?: ClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise { - return this._performPointerAction(point => this._page.mouse.click(point.x, point.y, options), options); + return this._retryPointerAction(point => this._page.mouse.click(point.x, point.y, options), options); } dblclick(options?: MultiClickOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise { - return this._performPointerAction(point => this._page.mouse.dblclick(point.x, point.y, options), options); + return this._retryPointerAction(point => this._page.mouse.dblclick(point.x, point.y, options), options); } async selectOption(values: string | ElementHandle | types.SelectOption | string[] | ElementHandle[] | types.SelectOption[], options?: types.NavigatingActionWaitOptions): Promise { @@ -429,11 +461,10 @@ export class ElementHandle extends js.JSHandle { const timeoutMessage = 'element to be displayed and not moving'; const injectedResult = await helper.waitWithDeadline(stablePromise, timeoutMessage, deadline); handleInjectedResult(injectedResult, timeoutMessage); - this._page._log(inputLog, '...done'); + this._page._log(inputLog, '...element is displayed and does not move'); } - async _waitForHitTargetAt(point: types.Point, deadline: number): Promise { - this._page._log(inputLog, `waiting for element to receive pointer events at (${point.x},${point.y}) ...`); + async _checkHitTargetAt(point: types.Point): Promise { const frame = await this.ownerFrame(); if (frame && frame.parentFrame()) { const element = await frame.frameElement(); @@ -443,13 +474,10 @@ export class ElementHandle extends js.JSHandle { // Translate from viewport coordinates to frame coordinates. point = { x: point.x - box.x, y: point.y - box.y }; } - const hitTargetPromise = this._evaluateInUtility(({ injected, node }, { timeout, point }) => { - return injected.waitForHitTargetAt(node, timeout, point); - }, { timeout: helper.timeUntilDeadline(deadline), point }); - const timeoutMessage = 'element to receive pointer events'; - const injectedResult = await helper.waitWithDeadline(hitTargetPromise, timeoutMessage, deadline); - handleInjectedResult(injectedResult, timeoutMessage); - this._page._log(inputLog, '...done'); + const injectedResult = await this._evaluateInUtility(({ injected, node }, { point }) => { + return injected.checkHitTargetAt(node, point); + }, { point }); + return handleInjectedResult(injectedResult, ''); } } @@ -470,3 +498,10 @@ function handleInjectedResult(injectedResult: InjectedResult, throw new Error(injectedResult.error); return injectedResult.value as T; } + +function roundPoint(point: types.Point): types.Point { + return { + x: (point.x * 100 | 0) / 100, + y: (point.y * 100 | 0) / 100, + }; +} diff --git a/src/firefox/ffPage.ts b/src/firefox/ffPage.ts index 9390b90612..e9e0655cd6 100644 --- a/src/firefox/ffPage.ts +++ b/src/firefox/ffPage.ts @@ -428,6 +428,9 @@ export class FFPage implements PageDelegate { }); } + async setActivityPaused(paused: boolean): Promise { + } + async getContentQuads(handle: dom.ElementHandle): Promise { const result = await this._session.send('Page.getContentQuads', { frameId: handle._context.frame._id, diff --git a/src/injected/injected.ts b/src/injected/injected.ts index 9771159c47..9e56c0ee49 100644 --- a/src/injected/injected.ts +++ b/src/injected/injected.ts @@ -324,30 +324,16 @@ export class Injected { return { status: result === 'notconnected' ? 'notconnected' : (result ? 'success' : 'timeout') }; } - async waitForHitTargetAt(node: Node, timeout: number, point: types.Point): Promise { - const targetElement = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement; - let element = targetElement; + checkHitTargetAt(node: Node, point: types.Point): InjectedResult { + let element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement; while (element && window.getComputedStyle(element).pointerEvents === 'none') element = element.parentElement; - if (!element) + if (!element || !element.isConnected) return { status: 'notconnected' }; - const result = await this.poll('raf', timeout, (): 'notconnected' | 'moved' | boolean => { - if (!element!.isConnected) - return 'notconnected'; - const clientRect = targetElement!.getBoundingClientRect(); - if (clientRect.left > point.x || clientRect.left + clientRect.width < point.x || - clientRect.top > point.y || clientRect.top + clientRect.height < point.y) - return 'moved'; - let hitElement = this._deepElementFromPoint(document, point.x, point.y); - while (hitElement && hitElement !== element) - hitElement = this._parentElementOrShadowHost(hitElement); - return hitElement === element; - }); - if (result === 'notconnected') - return { status: 'notconnected' }; - if (result === 'moved') - return { status: 'error', error: 'Element has moved during the action' }; - return { status: result ? 'success' : 'timeout' }; + let hitElement = this._deepElementFromPoint(document, point.x, point.y); + while (hitElement && hitElement !== element) + hitElement = this._parentElementOrShadowHost(hitElement); + return { status: 'success', value: hitElement === element }; } dispatchEvent(node: Node, type: string, eventInit: Object) { diff --git a/src/page.ts b/src/page.ts index c50ee27d23..869da5d791 100644 --- a/src/page.ts +++ b/src/page.ts @@ -69,6 +69,7 @@ export interface PageDelegate { getBoundingBox(handle: dom.ElementHandle): Promise; getFrameElement(frame: frames.Frame): Promise; scrollRectIntoViewIfNeeded(handle: dom.ElementHandle, rect?: types.Rect): Promise; + setActivityPaused(paused: boolean): Promise; getAccessibilityTree(needle?: dom.ElementHandle): Promise<{tree: accessibility.AXNode, needle: accessibility.AXNode | null}>; pdf?: (options?: types.PDFOptions) => Promise; diff --git a/src/webkit/wkPage.ts b/src/webkit/wkPage.ts index 6349c02229..8a79327bf9 100644 --- a/src/webkit/wkPage.ts +++ b/src/webkit/wkPage.ts @@ -731,6 +731,9 @@ export class WKPage implements PageDelegate { }); } + async setActivityPaused(paused: boolean): Promise { + } + async getContentQuads(handle: dom.ElementHandle): Promise { const result = await this._session.send('DOM.getContentQuads', { objectId: toRemoteObject(handle).objectId! diff --git a/test/assets/input/animating-button.html b/test/assets/input/animating-button.html index a3a05fd6ab..2946f2e89f 100644 --- a/test/assets/input/animating-button.html +++ b/test/assets/input/animating-button.html @@ -14,6 +14,7 @@ function addButton() { button.addEventListener('click', () => window.clicked = true); document.body.appendChild(button); } + function stopButton(remove) { const button = document.querySelector('button'); button.style.marginLeft = button.getBoundingClientRect().left + 'px'; @@ -21,14 +22,21 @@ function stopButton(remove) { if (remove) button.remove(); } -function startJumping() { + +let x = 0; +function jump() { + x += 300; const button = document.querySelector('button'); - let x = 0; + button.style.marginLeft = x + 'px'; +} + +function startJumping() { + x = 0; const moveIt = () => { - x += 300; - button.style.marginLeft = x + 'px'; + jump(); requestAnimationFrame(moveIt); }; + setInterval(jump, 0); moveIt(); } diff --git a/test/click.spec.js b/test/click.spec.js index bd5764c964..422b1f85ea 100644 --- a/test/click.spec.js +++ b/test/click.spec.js @@ -221,7 +221,6 @@ describe('Page.click', function() { 'mouseover', 'mouseenter', 'mousemove', - 'mousemove', 'mousedown', 'mouseup', 'click', @@ -571,37 +570,50 @@ describe('Page.click', function() { expect(clicked).toBe(true); expect(await page.evaluate(() => window.clicked)).toBe(true); }); - it('should fail when element moves during hit testing', async({page, server}) => { + it('should retry when element jumps during hit testing', async({page, server}) => { await page.goto(server.PREFIX + '/input/animating-button.html'); await page.evaluate(() => addButton()); let clicked = false; const handle = await page.$('button'); - const __testHookBeforeWaitForHitTarget = () => page.evaluate(() => startJumping()); - const promise = handle.click({ timeout: 0, __testHookBeforeWaitForHitTarget }).then(() => clicked = true).catch(e => e); + const __testHookBeforeHitTarget = () => page.evaluate(() => { if (window.x === 0) jump(); }); + const promise = handle.click({ timeout: 0, __testHookBeforeHitTarget }).then(() => clicked = true); expect(clicked).toBe(false); expect(await page.evaluate(() => window.clicked)).toBe(undefined); await page.evaluate(() => stopButton()); + await promise; + expect(clicked).toBe(true); + expect(await page.evaluate(() => window.clicked)).toBe(true); + }); + it('should fail when element jumps during hit testing', async({page, server}) => { + await page.goto(server.PREFIX + '/input/animating-button.html'); + await page.evaluate(() => addButton()); + await page.evaluate(() => stopButton()); + let clicked = false; + const handle = await page.$('button'); + const __testHookBeforeHitTarget = () => page.evaluate(() => jump()); + const promise = handle.click({ timeout: 1000, __testHookBeforeHitTarget, __testHookSkipStablePosition: true }).then(() => clicked = true).catch(e => e); const error = await promise; expect(clicked).toBe(false); - expect(error.message).toBe('Element has moved during the action'); expect(await page.evaluate(() => window.clicked)).toBe(undefined); - }); - it('should fail when element is blocked on hover', async({page, server}) => { - await page.setContent(` - - -
-
`); - const error = await page.click('button', { timeout: 3000 }).catch(e => e); expect(error.message).toBe('waiting for element to receive pointer events failed: timeout exceeded'); - expect(await page.evaluate(() => window.clicked)).toBe(undefined); }); - it('should wait while element is blocked on hover', async({page, server}) => { + it.fail(CHROMIUM || WEBKIT || FFOX)('should work when element jumps uncontrollably', async({page, server}) => { + // This test requires pausing the page. + await page.goto(server.PREFIX + '/input/animating-button.html'); + await page.evaluate(() => addButton()); + await page.evaluate(() => stopButton()); + const handle = await page.$('button'); + await page.evaluate(() => startJumping()); + let clicked = false; + const promise = handle.click({ timeout: 1000, __testHookSkipStablePosition: true }).then(() => clicked = true); + expect(clicked).toBe(false); + expect(await page.evaluate(() => window.clicked)).toBe(undefined); + await promise; + expect(clicked).toBe(true); + expect(await page.evaluate(() => window.clicked)).toBe(true); + }); + it.fail(CHROMIUM || WEBKIT || FFOX)('should wait while element is blocked on hover', async({page, server}) => { + // This test requires pausing the page. await page.setContent(`