diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index cd80bf9546..0b5979678f 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -869,16 +869,23 @@ export class ElementHandle extends js.JSHandle { return result; } - async _checkFrameIsHitTarget(point: types.Point): Promise<{ framePoint: types.Point } | 'error:notconnected' | { hitTargetDescription: string }> { + async _checkFrameIsHitTarget(point: types.Point): Promise<{ framePoint: types.Point | undefined } | 'error:notconnected' | { hitTargetDescription: string }> { let frame = this._frame; const data: { frame: frames.Frame, frameElement: ElementHandle | null, pointInFrame: types.Point }[] = []; while (frame.parentFrame()) { const frameElement = await frame.frameElement() as ElementHandle; const box = await frameElement.boundingBox(); - if (!box) + const style = await frameElement.evaluateInUtility(([injected, iframe]) => injected.describeIFrameStyle(iframe), {}).catch(e => 'error:notconnected' as const); + if (!box || style === 'error:notconnected') return 'error:notconnected'; + if (style === 'transformed') { + // We cannot translate coordinates when iframe has any transform applied. + // The best we can do right now is to skip the hitPoint check, + // and solely rely on the event interceptor. + return { framePoint: undefined }; + } // Translate from viewport coordinates to frame coordinates. - const pointInFrame = { x: point.x - box.x, y: point.y - box.y }; + const pointInFrame = { x: point.x - box.x - style.borderLeft, y: point.y - box.y - style.borderTop }; data.push({ frame, frameElement, pointInFrame }); frame = frame.parentFrame()!; } diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index c1c4200c9f..b2621fc72e 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -507,6 +507,18 @@ export class InjectedScript { return { left: parseInt(style.borderLeftWidth || '', 10), top: parseInt(style.borderTopWidth || '', 10) }; } + describeIFrameStyle(iframe: Element): 'error:notconnected' | 'transformed' | { borderLeft: number, borderTop: number } { + if (!iframe.ownerDocument || !iframe.ownerDocument.defaultView) + return 'error:notconnected'; + const defaultView = iframe.ownerDocument.defaultView; + for (let e: Element | undefined = iframe; e; e = parentElementOrShadowHost(e)) { + if (defaultView.getComputedStyle(e).transform !== 'none') + return 'transformed'; + } + const iframeStyle = defaultView.getComputedStyle(iframe); + return { borderLeft: parseInt(iframeStyle.borderLeftWidth || '', 10), borderTop: parseInt(iframeStyle.borderTopWidth || '', 10) }; + } + retarget(node: Node, behavior: 'none' | 'follow-label' | 'no-follow-label' | 'button-link'): Element | null { let element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement; if (!element) @@ -901,16 +913,18 @@ export class InjectedScript { // 2k. (injected) Event interceptor is removed. // 2l. All navigations triggered between 2g-2k are awaited to be either committed or canceled. // 2m. If failed, wait for increasing amount of time before the next retry. - setupHitTargetInterceptor(node: Node, action: 'hover' | 'tap' | 'mouse' | 'drag', hitPoint: { x: number, y: number }, blockAllEvents: boolean): HitTargetInterceptionResult | 'error:notconnected' | string /* hitTargetDescription */ { + setupHitTargetInterceptor(node: Node, action: 'hover' | 'tap' | 'mouse' | 'drag', hitPoint: { x: number, y: number } | undefined, blockAllEvents: boolean): HitTargetInterceptionResult | 'error:notconnected' | string /* hitTargetDescription */ { const element = this.retarget(node, 'button-link'); if (!element || !element.isConnected) return 'error:notconnected'; - // First do a preliminary check, to reduce the possibility of some iframe - // intercepting the action. - const preliminaryResult = this.expectHitTarget(hitPoint, element); - if (preliminaryResult !== 'done') - return preliminaryResult.hitTargetDescription; + if (hitPoint) { + // First do a preliminary check, to reduce the possibility of some iframe + // intercepting the action. + const preliminaryResult = this.expectHitTarget(hitPoint, element); + if (preliminaryResult !== 'done') + return preliminaryResult.hitTargetDescription; + } // 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 - diff --git a/tests/library/hit-target.spec.ts b/tests/library/hit-target.spec.ts index 45859eb59a..2564efc6f5 100644 --- a/tests/library/hit-target.spec.ts +++ b/tests/library/hit-target.spec.ts @@ -362,3 +362,35 @@ it('should detect overlay from another shadow root', async ({ page, server }) => const error = await page.locator('#container1 >> text=click me').click({ timeout: 2000 }).catch(e => e); expect(error.message).toContain(`
intercepts pointer events`); }); + +it('should detect overlayed element in a transformed iframe', async ({ page }) => { + await page.setContent(` + + + `); + const locator = page.frameLocator('iframe').locator('div'); + const error = await locator.click({ timeout: 2000 }).catch(e => e); + expect(error.message).toContain('
Overlay
intercepts pointer events'); +}); diff --git a/tests/page/page-click.spec.ts b/tests/page/page-click.spec.ts index c11139e225..166bf1da7a 100644 --- a/tests/page/page-click.spec.ts +++ b/tests/page/page-click.spec.ts @@ -32,7 +32,7 @@ it('should click the button @smoke', async ({ page, server }) => { it('should click button inside frameset', async ({ page, server }) => { await page.goto(server.PREFIX + '/frames/frameset.html'); const frameElement = await page.$('frame'); - await frameElement.evaluate(frame => frame.src = '/input/button.html'); + await frameElement.evaluate((frame: HTMLFrameElement) => frame.src = '/input/button.html'); const frame = await frameElement.contentFrame(); await frame.click('button'); expect(await frame.evaluate('result')).toBe('Clicked'); @@ -886,3 +886,126 @@ it('should climb up to a [role=link]', async ({ page }) => { await page.click('#inner'); expect(await page.evaluate('__CLICKED')).toBe(true); }); + +it('should click in an iframe with border', async ({ page }) => { + await page.setContent(` + + + `); + const locator = page.frameLocator('iframe').locator('div'); + await locator.click(); + expect(await page.evaluate('window._clicked')).toBe(true); +}); + +it('should click in an iframe with border 2', async ({ page }) => { + await page.setContent(` + + + `); + const locator = page.frameLocator('iframe').locator('div'); + await locator.click(); + expect(await page.evaluate('window._clicked')).toBe(true); +}); + +it('should click in a transformed iframe', async ({ page }) => { + await page.setContent(` + + + `); + const locator = page.frameLocator('iframe').locator('div'); + await locator.click(); + expect(await page.evaluate('window._clicked')).toBe(true); +}); + +it('should click in a transformed iframe with force', async ({ page }) => { + await page.setContent(` + + + `); + const locator = page.frameLocator('iframe').locator('div'); + await locator.click({ force: true }); + expect(await page.evaluate('window._clicked')).toBe(true); +}); + +it('should click in a nested transformed iframe', async ({ page }) => { + await page.setContent(` + + + `); + const locator = page.frameLocator('iframe').frameLocator('iframe').locator('div'); + await locator.evaluate(div => { + div.addEventListener('click', () => window.top['_clicked'] = true); + }); + await locator.click(); + expect(await page.evaluate('window._clicked')).toBe(true); +});