mirror of
https://github.com/microsoft/playwright.git
synced 2024-12-12 11:50:22 +03:00
fix(click): allow clicking inside closed shadow root (#16900)
Although Playwright selectors do not pierce closed shadow roots, one can still obtain a reference to an element inside a closed shadow root: - through `page.evaluate()`; - through `handle.$()` where `handle` is inside the shadow root; - through `frame.locator()` by choosing an iframe that belongs to a closed shadow root. In this case, `click()` action fails during the hit check test, but it's possible to make it work by going bottom up from the target rather than top down from the document.
This commit is contained in:
parent
fbf9ca5316
commit
6e1c94b5fe
@ -882,8 +882,7 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
||||
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;
|
||||
|
@ -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 || ''}`);
|
||||
|
@ -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(`
|
||||
<div id=framecontainer>
|
||||
</div>
|
||||
<script>
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.setAttribute('name', 'myframe');
|
||||
iframe.setAttribute('srcdoc', '<div onclick="window.top.__clicked = true">click me</div>');
|
||||
const div = document.getElementById('framecontainer');
|
||||
const host = div.attachShadow({ mode: 'closed' });
|
||||
host.appendChild(iframe);
|
||||
</script>
|
||||
`);
|
||||
|
||||
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(`
|
||||
<div id=container>
|
||||
</div>
|
||||
<script>
|
||||
const span = document.createElement('span');
|
||||
span.textContent = 'click me';
|
||||
span.addEventListener('click', () => window.__clicked = true);
|
||||
const div = document.getElementById('container');
|
||||
const host = div.attachShadow({ mode: 'closed' });
|
||||
host.appendChild(span);
|
||||
window.__target = span;
|
||||
</script>
|
||||
`);
|
||||
|
||||
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(`
|
||||
<style>
|
||||
div > div {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
span {
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
</style>
|
||||
<div style="position:relative; width:300px; height:300px">
|
||||
<div id=container1></div>
|
||||
<div id=container2></div>
|
||||
</div>
|
||||
<script>
|
||||
for (const id of ['container1', 'container2']) {
|
||||
const span = document.createElement('span');
|
||||
span.id = id + '-span';
|
||||
span.textContent = 'click me';
|
||||
span.style.display = 'block';
|
||||
span.style.position = 'absolute';
|
||||
span.style.left = '20px';
|
||||
span.style.top = '20px';
|
||||
span.style.width = '300px';
|
||||
span.style.height = '300px';
|
||||
span.addEventListener('click', () => window.__clicked = id);
|
||||
const div = document.getElementById(id);
|
||||
const host = div.attachShadow({ mode: 'open' });
|
||||
host.appendChild(span);
|
||||
}
|
||||
</script>
|
||||
`);
|
||||
|
||||
const error = await page.locator('#container1 >> text=click me').click({ timeout: 2000 }).catch(e => e);
|
||||
expect(error.message).toContain(`<div id="container2"></div> intercepts pointer events`);
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user