diff --git a/packages/playwright-core/src/server/dom.ts b/packages/playwright-core/src/server/dom.ts index 1125f75e2a..2dd0b323b2 100644 --- a/packages/playwright-core/src/server/dom.ts +++ b/packages/playwright-core/src/server/dom.ts @@ -882,8 +882,7 @@ export class ElementHandle extends js.JSHandle { const point = data[i].pointInFrame; // Hit target in the parent frame should hit the child frame element. const hitTargetResult = await element.evaluateInUtility(([injected, element, hitPoint]) => { - const hitElement = injected.deepElementFromPoint(document, hitPoint.x, hitPoint.y); - return injected.expectHitTargetParent(hitElement, element); + return injected.expectHitTarget(hitPoint, element); }, point); if (hitTargetResult !== 'done') return hitTargetResult; diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 2c8b9604cb..df9f119887 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -23,7 +23,7 @@ import type { NestedSelectorBody, ParsedSelector, ParsedSelectorPart } from '../ import { allEngineNames, parseSelector, stringifySelector } from '../isomorphic/selectorParser'; import { type TextMatcher, elementMatchesText, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher, elementText } from './selectorUtils'; import { SelectorEvaluatorImpl } from './selectorEvaluator'; -import { isElementVisible, parentElementOrShadowHost } from './domUtils'; +import { enclosingShadowRootOrDocument, isElementVisible, parentElementOrShadowHost } from './domUtils'; import type { CSSComplexSelectorList } from '../isomorphic/cssParser'; import { generateSelector } from './selectorGenerator'; import type * as channels from '../../protocol/channels'; @@ -726,7 +726,49 @@ export class InjectedScript { input.dispatchEvent(new Event('change', { 'bubbles': true })); } - expectHitTargetParent(hitElement: Element | undefined, targetElement: Element) { + expectHitTarget(hitPoint: { x: number, y: number }, targetElement: Element) { + const roots: (Document | ShadowRoot)[] = []; + + // Get all component roots leading to the target element. + // Go from the bottom to the top to make it work with closed shadow roots. + let parentElement = targetElement; + while (parentElement) { + const root = enclosingShadowRootOrDocument(parentElement); + if (!root) + break; + roots.push(root); + if (root.nodeType === 9 /* Node.DOCUMENT_NODE */) + break; + parentElement = (root as ShadowRoot).host; + } + + // Hit target in each component root should point to the next component root. + // Hit target in the last component root should point to the target or its descendant. + let hitElement: Element | undefined; + for (let index = roots.length - 1; index >= 0; index--) { + const root = roots[index]; + // All browsers have different behavior around elementFromPoint and elementsFromPoint. + // https://github.com/w3c/csswg-drafts/issues/556 + // http://crbug.com/1188919 + const elements: Element[] = root.elementsFromPoint(hitPoint.x, hitPoint.y); + const singleElement = root.elementFromPoint(hitPoint.x, hitPoint.y); + if (singleElement && elements[0] && parentElementOrShadowHost(singleElement) === elements[0]) { + const style = document.defaultView?.getComputedStyle(singleElement); + if (style?.display === 'contents') { + // Workaround a case where elementsFromPoint misses the inner-most element with display:contents. + // https://bugs.chromium.org/p/chromium/issues/detail?id=1342092 + elements.unshift(singleElement); + } + } + const innerElement = elements[0] as Element | undefined; + if (!innerElement) + break; + hitElement = innerElement; + if (index && innerElement !== (roots[index - 1] as ShadowRoot).host) + break; + } + + // Check whether hit target is the target or its descendant. const hitParents: Element[] = []; while (hitElement && hitElement !== targetElement) { hitParents.push(hitElement); @@ -734,6 +776,7 @@ export class InjectedScript { } if (hitElement === targetElement) return 'done'; + const hitTargetDescription = this.previewNode(hitParents[0] || document.documentElement); // 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 @@ -791,8 +834,7 @@ export class InjectedScript { // First do a preliminary check, to reduce the possibility of some iframe // intercepting the action. - const preliminaryHitElement = this.deepElementFromPoint(document, hitPoint.x, hitPoint.y); - const preliminaryResult = this.expectHitTargetParent(preliminaryHitElement, element); + const preliminaryResult = this.expectHitTarget(hitPoint, element); if (preliminaryResult !== 'done') return preliminaryResult.hitTargetDescription; @@ -825,10 +867,8 @@ export class InjectedScript { // Check that we hit the right element at the first event, and assume all // subsequent events will be fine. - if (result === undefined && point) { - const hitElement = this.deepElementFromPoint(document, point.clientX, point.clientY); - result = this.expectHitTargetParent(hitElement, element); - } + if (result === undefined && point) + result = this.expectHitTarget({ x: point.clientX, y: point.clientY }, element); if (blockAllEvents || (result !== 'done' && result !== undefined)) { event.preventDefault(); @@ -869,32 +909,6 @@ export class InjectedScript { node.dispatchEvent(event); } - deepElementFromPoint(document: Document, x: number, y: number): Element | undefined { - let container: Document | ShadowRoot | null = document; - let element: Element | undefined; - while (container) { - // All browsers have different behavior around elementFromPoint and elementsFromPoint. - // https://github.com/w3c/csswg-drafts/issues/556 - // http://crbug.com/1188919 - const elements: Element[] = container.elementsFromPoint(x, y); - const singleElement = container.elementFromPoint(x, y); - if (singleElement && elements[0] && parentElementOrShadowHost(singleElement) === elements[0]) { - const style = document.defaultView?.getComputedStyle(singleElement); - if (style?.display === 'contents') { - // Workaround a case where elementsFromPoint misses the inner-most element with display:contents. - // https://bugs.chromium.org/p/chromium/issues/detail?id=1342092 - elements.unshift(singleElement); - } - } - const innerElement = elements[0] as Element | undefined; - if (!innerElement || element === innerElement) - break; - element = innerElement; - container = element.shadowRoot; - } - return element; - } - previewNode(node: Node): string { if (node.nodeType === Node.TEXT_NODE) return oneLine(`#text=${node.nodeValue || ''}`); diff --git a/tests/library/hit-target.spec.ts b/tests/library/hit-target.spec.ts index 2825296e6f..45859eb59a 100644 --- a/tests/library/hit-target.spec.ts +++ b/tests/library/hit-target.spec.ts @@ -15,6 +15,7 @@ */ import { contextTest as it, expect } from '../config/browserTest'; +import type { ElementHandle } from 'playwright-core'; declare const renderComponent; declare const e; @@ -273,3 +274,91 @@ it('should not click an element overlaying iframe with the target', async ({ pag await target.click(); expect(await page.evaluate('window._clicked')).toBe(3); }); + +it('should click into frame inside closed shadow root', async ({ page, server }) => { + await page.goto(server.EMPTY_PAGE); + await page.setContent(` +
+
+ + `); + + const frame = page.frame({ name: 'myframe' }); + await frame.locator('text=click me').click(); + expect(await page.evaluate('window.__clicked')).toBe(true); +}); + +it('should click an element inside closed shadow root', async ({ page, server }) => { + await page.goto(server.EMPTY_PAGE); + await page.setContent(` +
+
+ + `); + + const handle = await page.evaluateHandle('window.__target'); + await (handle as any as ElementHandle).click(); + expect(await page.evaluate('window.__clicked')).toBe(true); +}); + +it('should detect overlay from another shadow root', async ({ page, server }) => { + await page.goto(server.EMPTY_PAGE); + await page.setContent(` + +
+
+
+
+ + `); + + const error = await page.locator('#container1 >> text=click me').click({ timeout: 2000 }).catch(e => e); + expect(error.message).toContain(`
intercepts pointer events`); +});