From c4581e54c0b54e395ee9d87fb2b91e1438bc3a93 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 1 Jun 2022 15:23:41 -0700 Subject: [PATCH] fix(click): detect iframe overlays that cover target element (#13876) This restores the old hit target check, in addition to the new hit target interceptor. This way, we got some coverage for iframes and other quirky cases, but keep the bullet-proof hit target check in place. --- packages/playwright-core/src/server/dom.ts | 51 ++++++++---------- .../src/server/injected/injectedScript.ts | 54 +++++++++++++++---- tests/library/hit-target.spec.ts | 15 ++++++ 3 files changed, 82 insertions(+), 38 deletions(-) diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index 8c0c54bd68..e1942d13fd 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -436,34 +436,25 @@ export class ElementHandle extends js.JSHandle { if ((options as any).__testHookBeforeHitTarget) await (options as any).__testHookBeforeHitTarget(); - if (actionName === 'move and up') { - // When dropping, the "element that is being dragged" often stays under the cursor, - // so hit target check at the moment we receive mousedown does not work - - // it finds the "element that is being dragged" instead of the - // "element that we drop onto". - progress.log(` checking that element receives pointer events at (${point.x},${point.y})`); - const hitTargetResult = await this._checkHitTargetAt(point); - if (hitTargetResult !== 'done') - return hitTargetResult; - progress.log(` element does receive pointer events`); - if (options.trial) { - progress.log(` trial ${actionName} has finished`); - return 'done'; - } - } else { - const actionType = (actionName === 'hover' || actionName === 'tap') ? actionName : 'mouse'; - const handle = await this.evaluateHandleInUtility(([injected, node, { actionType, trial }]) => injected.setupHitTargetInterceptor(node, actionType, trial), { actionType, trial: !!options.trial } as const); - if (handle === 'error:notconnected') - return handle; - if (!handle._objectId) - return handle.rawValue() as 'error:notconnected'; - hitTargetInterceptionHandle = handle as any; - progress.cleanupWhenAborted(() => { - // Do not await here, just in case the renderer is stuck (e.g. on alert) - // and we won't be able to cleanup. - hitTargetInterceptionHandle!.evaluate(h => h.stop()).catch(e => {}); - }); + const hitPoint = await this._viewportPointToDocument(point); + if (hitPoint === 'error:notconnected') + return hitPoint; + const actionType = actionName === 'move and up' ? 'drag' : ((actionName === 'hover' || actionName === 'tap') ? actionName : 'mouse'); + const handle = await this.evaluateHandleInUtility(([injected, node, { actionType, hitPoint, trial }]) => injected.setupHitTargetInterceptor(node, actionType, hitPoint, trial), { actionType, hitPoint, trial: !!options.trial } as const); + if (handle === 'error:notconnected') + return handle; + if (!handle._objectId) { + const error = handle.rawValue() as string; + if (error === 'error:notconnected') + return error; + return { hitTargetDescription: error }; } + hitTargetInterceptionHandle = handle as any; + progress.cleanupWhenAborted(() => { + // Do not await here, just in case the renderer is stuck (e.g. on alert) + // and we won't be able to cleanup. + hitTargetInterceptionHandle!.evaluate(h => h.stop()).catch(e => {}); + }); } const actionResult = await this._page._frameManager.waitForSignalsCreatedBy(progress, options.noWaitAfter, async () => { @@ -864,7 +855,9 @@ export class ElementHandle extends js.JSHandle { return result; } - async _checkHitTargetAt(point: types.Point): Promise<'error:notconnected' | { hitTargetDescription: string } | 'done'> { + async _viewportPointToDocument(point: types.Point): Promise { + if (!this._frame.parentFrame()) + return point; const frame = await this.ownerFrame(); if (frame && frame.parentFrame()) { const element = await frame.frameElement(); @@ -874,7 +867,7 @@ export class ElementHandle extends js.JSHandle { // Translate from viewport coordinates to frame coordinates. point = { x: point.x - box.x, y: point.y - box.y }; } - return this.evaluateInUtility(([injected, node, point]) => injected.checkHitTargetAt(node, point), point); + return point; } } diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 33d6f6a549..c9c5896b6c 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -715,14 +715,6 @@ export class InjectedScript { input.dispatchEvent(new Event('change', { 'bubbles': true })); } - checkHitTargetAt(node: Node, point: { x: number, y: number }): 'error:notconnected' | 'done' | { hitTargetDescription: string } { - const element: Element | null | undefined = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement; - if (!element || !element.isConnected) - return 'error:notconnected'; - const hitElement = this.deepElementFromPoint(document, point.x, point.y); - return this._expectHitTargetParent(hitElement, element); - } - private _expectHitTargetParent(hitElement: Element | undefined, targetElement: Element) { targetElement = targetElement.closest('button, [role=button], a, [role=link]') || targetElement; const hitParents: Element[] = []; @@ -752,11 +744,55 @@ export class InjectedScript { return { hitTargetDescription }; } - setupHitTargetInterceptor(node: Node, action: 'hover' | 'tap' | 'mouse', blockAllEvents: boolean): HitTargetInterceptionResult | 'error:notconnected' { + // Life of a pointer action, for example click. + // + // 0. Retry items 1 and 2 while action fails due to navigation or element being detached. + // 1. Resolve selector to an element. + // 2. Retry the following steps until the element is detached or frame navigates away. + // 2a. Wait for the element to be stable (not moving), visible and enabled. + // 2b. Scroll element into view. Scrolling alternates between: + // - Built-in protocol scrolling. + // - Anchoring to the top/left, bottom/right and center/center. + // This is to scroll elements from under sticky headers/footers. + // 2c. Click point is calculated, either based on explicitly specified position, + // or some visible point of the element based on protocol content quads. + // 2d. Click point relative to page viewport is converted relative to the target iframe + // for the next hit-point check. + // 2e. (injected) Hit target at the click point must be a descendant of the target element. + // This prevents mis-clicking in edge cases like + + `); + const error = await page.click('text=click-me', { timeout: 500 }).catch(e => e); + expect(await page.evaluate('window._clicked')).toBe(undefined); + expect(error.message).toContain(`