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(`