From 69e1e713efb7fc5491e980c64e2a83363926a515 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 14 Aug 2020 14:48:36 -0700 Subject: [PATCH] feat(click): provide preview of the element intercepting pointer events (#3449) --- src/dom.ts | 10 +++++----- src/injected/injectedScript.ts | 29 ++++++++++++++++++++++++---- test/click-timeout-3.spec.ts | 35 ++++++++++++++++++++++++++++++++-- 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/dom.ts b/src/dom.ts index 1f2c2d34ae..139733dbe0 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -306,10 +306,10 @@ export class ElementHandle extends js.JSHandle { progress.logger.info(' element is outside of the viewport'); continue; } - if (result === 'error:nothittarget') { + if (typeof result === 'object' && 'hitTargetDescription' in result) { if (options.force) - throw new Error('Element does not receive pointer events'); - progress.logger.info(' element does not receive pointer events'); + throw new Error(`Element does not receive pointer events, ${result.hitTargetDescription} intercepts them`); + progress.logger.info(` ${result.hitTargetDescription} intercepts pointer events`); continue; } return result; @@ -317,7 +317,7 @@ export class ElementHandle extends js.JSHandle { return 'done'; } - async _performPointerAction(progress: Progress, actionName: string, waitForEnabled: boolean, action: (point: types.Point) => Promise, options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'error:notvisible' | 'error:notconnected' | 'error:notinviewport' | 'error:nothittarget' | 'done'> { + async _performPointerAction(progress: Progress, actionName: string, waitForEnabled: boolean, action: (point: types.Point) => Promise, options: types.PointerActionOptions & types.PointerActionWaitOptions & types.NavigatingActionWaitOptions): Promise<'error:notvisible' | 'error:notconnected' | 'error:notinviewport' | { hitTargetDescription: string } | 'done'> { const { force = false, position } = options; if ((options as any).__testHookBeforeStable) await (options as any).__testHookBeforeStable(); @@ -685,7 +685,7 @@ export class ElementHandle extends js.JSHandle { return result; } - async _checkHitTargetAt(point: types.Point): Promise<'error:notconnected' | 'error:nothittarget' | 'done'> { + async _checkHitTargetAt(point: types.Point): Promise<'error:notconnected' | { hitTargetDescription: string } | 'done'> { const frame = await this.ownerFrame(); if (frame && frame.parentFrame()) { const element = await frame.frameElement(); diff --git a/src/injected/injectedScript.ts b/src/injected/injectedScript.ts index 6ed217b1cd..2f2a87b25a 100644 --- a/src/injected/injectedScript.ts +++ b/src/injected/injectedScript.ts @@ -479,15 +479,36 @@ export default class InjectedScript { }); } - checkHitTargetAt(node: Node, point: types.Point): 'error:notconnected' | 'error:nothittarget' | 'done' { - let element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement; + checkHitTargetAt(node: Node, point: types.Point): 'error:notconnected' | 'done' | { hitTargetDescription: string } { + let element: Element | null | undefined = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement; if (!element || !element.isConnected) return 'error:notconnected'; element = element.closest('button, [role=button]') || element; let hitElement = this.deepElementFromPoint(document, point.x, point.y); - while (hitElement && hitElement !== element) + const hitParents: Element[] = []; + while (hitElement && hitElement !== element) { + hitParents.push(hitElement); hitElement = this._parentElementOrShadowHost(hitElement); - return hitElement === element ? 'done' : 'error:nothittarget'; + } + if (hitElement === element) + return 'done'; + const hitTargetDescription = this.previewNode(hitParents[0]); + // Root is the topmost element in the hitTarget's chain that is not in the + // element's chain. For example, it might be a dialog element that overlays + // the target. + let rootHitTargetDescription: string | undefined; + while (element) { + const index = hitParents.indexOf(element); + if (index !== -1) { + if (index > 1) + rootHitTargetDescription = this.previewNode(hitParents[index - 1]); + break; + } + element = this._parentElementOrShadowHost(element); + } + if (rootHitTargetDescription) + return { hitTargetDescription: `${hitTargetDescription} from ${rootHitTargetDescription} subtree` }; + return { hitTargetDescription }; } dispatchEvent(node: Node, type: string, eventInit: Object) { diff --git a/test/click-timeout-3.spec.ts b/test/click-timeout-3.spec.ts index 428bdc2ffc..8b77a4a9dd 100644 --- a/test/click-timeout-3.spec.ts +++ b/test/click-timeout-3.spec.ts @@ -31,7 +31,7 @@ it.skip(WIRE)('should fail when element jumps during hit testing', async({page, expect(clicked).toBe(false); expect(await page.evaluate('window.clicked')).toBe(undefined); expect(error.message).toContain('elementHandle.click: Timeout 5000ms exceeded.'); - expect(error.message).toContain('element does not receive pointer events'); + expect(error.message).toContain('… intercepts pointer events'); expect(error.message).toContain('retrying click action'); }); @@ -41,6 +41,7 @@ it('should timeout waiting for hit target', async({page, server}) => { await page.evaluate(() => { document.body.style.position = 'relative'; const blocker = document.createElement('div'); + blocker.id = 'blocker'; blocker.style.position = 'absolute'; blocker.style.width = '400px'; blocker.style.height = '20px'; @@ -50,6 +51,36 @@ it('should timeout waiting for hit target', async({page, server}) => { }); const error = await button.click({ timeout: 5000 }).catch(e => e); expect(error.message).toContain('elementHandle.click: Timeout 5000ms exceeded.'); - expect(error.message).toContain('element does not receive pointer events'); + expect(error.message).toContain('
intercepts pointer events'); + expect(error.message).toContain('retrying click action'); +}); + +it('should report wrong hit target subtree', async({page, server}) => { + await page.goto(server.PREFIX + '/input/button.html'); + const button = await page.$('button'); + await page.evaluate(() => { + document.body.style.position = 'relative'; + + const blocker = document.createElement('div'); + blocker.id = 'blocker'; + blocker.style.position = 'absolute'; + blocker.style.width = '400px'; + blocker.style.height = '20px'; + blocker.style.left = '0'; + blocker.style.top = '0'; + document.body.appendChild(blocker); + + const inner = document.createElement('div'); + inner.id = 'inner'; + inner.style.position = 'absolute'; + inner.style.left = '0'; + inner.style.top = '0'; + inner.style.right = '0'; + inner.style.bottom = '0'; + blocker.appendChild(inner); + }); + const error = await button.click({ timeout: 5000 }).catch(e => e); + expect(error.message).toContain('elementHandle.click: Timeout 5000ms exceeded.'); + expect(error.message).toContain('
from
subtree intercepts pointer events'); expect(error.message).toContain('retrying click action'); });