mirror of
https://github.com/microsoft/playwright.git
synced 2025-01-05 19:04:43 +03:00
fix(click): account for transformed iframes (#18926)
- Properly convert coordinates for iframes with non-zero borders. - IFrames that have `transform` anywhere in the ancestors skip `hitPoint`-based check because we cannot reliably translate the viewport point into frame document's coordinates. Fixes #18245.
This commit is contained in:
parent
b1e2b8b629
commit
941090f0c4
@ -869,16 +869,23 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
|
||||
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<Element> | null, pointInFrame: types.Point }[] = [];
|
||||
while (frame.parentFrame()) {
|
||||
const frameElement = await frame.frameElement() as ElementHandle<Element>;
|
||||
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()!;
|
||||
}
|
||||
|
@ -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 -
|
||||
|
@ -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(`<div id="container2"></div> intercepts pointer events`);
|
||||
});
|
||||
|
||||
it('should detect overlayed element in a transformed iframe', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<style>
|
||||
body, html, iframe { margin: 0; padding: 0; border: none; }
|
||||
iframe {
|
||||
border: 4px solid black;
|
||||
background: gray;
|
||||
margin-left: 33px;
|
||||
margin-top: 24px;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
</style>
|
||||
<iframe srcdoc="
|
||||
<style>
|
||||
body, html { margin: 0; padding: 0; }
|
||||
div { margin-left: 10px; margin-top: 20px; width: 2px; height: 2px; }
|
||||
section { position: absolute; top: 0; left: 0; bottom: 0; right: 0; }
|
||||
</style>
|
||||
<div>Target</div>
|
||||
<section>Overlay</section>
|
||||
<script>
|
||||
document.querySelector('div').addEventListener('click', () => window.top._clicked = true);
|
||||
</script>
|
||||
"></iframe>
|
||||
`);
|
||||
const locator = page.frameLocator('iframe').locator('div');
|
||||
const error = await locator.click({ timeout: 2000 }).catch(e => e);
|
||||
expect(error.message).toContain('<section>Overlay</section> intercepts pointer events');
|
||||
});
|
||||
|
@ -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(`
|
||||
<style>
|
||||
body, html, iframe { margin: 0; padding: 0; border: none; box-sizing: border-box; }
|
||||
iframe { border: 4px solid black; background: gray; margin-left: 33px; margin-top: 24px; width: 400px; height: 400px; }
|
||||
</style>
|
||||
<iframe srcdoc="
|
||||
<style>
|
||||
body, html { margin: 0; padding: 0; }
|
||||
div { margin-left: 10px; margin-top: 20px; width: 2px; height: 2px; }
|
||||
</style>
|
||||
<div>Target</div>
|
||||
<script>
|
||||
document.querySelector('div').addEventListener('click', () => window.top._clicked = true);
|
||||
</script>
|
||||
"></iframe>
|
||||
`);
|
||||
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(`
|
||||
<style>
|
||||
body, html, iframe { margin: 0; padding: 0; border: none; }
|
||||
iframe { border: 4px solid black; background: gray; margin-left: 33px; margin-top: 24px; width: 400px; height: 400px; }
|
||||
</style>
|
||||
<iframe srcdoc="
|
||||
<style>
|
||||
body, html { margin: 0; padding: 0; }
|
||||
div { margin-left: 10px; margin-top: 20px; width: 2px; height: 2px; }
|
||||
</style>
|
||||
<div>Target</div>
|
||||
<script>
|
||||
document.querySelector('div').addEventListener('click', () => window.top._clicked = true);
|
||||
</script>
|
||||
"></iframe>
|
||||
`);
|
||||
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(`
|
||||
<style>
|
||||
body, html, iframe { margin: 0; padding: 0; border: none; }
|
||||
iframe {
|
||||
border: 4px solid black;
|
||||
background: gray;
|
||||
margin-left: 33px;
|
||||
margin-top: 24px;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
transform: translate(100px, 100px) scale(1.2) rotate3d(1, 1, 1, 25deg);
|
||||
}
|
||||
</style>
|
||||
<iframe srcdoc="
|
||||
<style>
|
||||
body, html { margin: 0; padding: 0; }
|
||||
div { margin-left: 10px; margin-top: 20px; width: 2px; height: 2px; }
|
||||
</style>
|
||||
<div>Target</div>
|
||||
<script>
|
||||
document.querySelector('div').addEventListener('click', () => window.top._clicked = true);
|
||||
</script>
|
||||
"></iframe>
|
||||
`);
|
||||
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(`
|
||||
<style>
|
||||
body, html, iframe { margin: 0; padding: 0; border: none; }
|
||||
iframe { background: gray; margin-left: 33px; margin-top: 24px; width: 400px; height: 400px; transform: translate(-40px, -40px) scale(0.8); }
|
||||
</style>
|
||||
<iframe srcdoc="
|
||||
<style>
|
||||
body, html { margin: 0; padding: 0; }
|
||||
div { margin-left: 10px; margin-top: 20px; width: 2px; height: 2px; }
|
||||
</style>
|
||||
<div>Target</div>
|
||||
<script>
|
||||
document.querySelector('div').addEventListener('click', () => window.top._clicked = true);
|
||||
</script>
|
||||
"></iframe>
|
||||
`);
|
||||
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(`
|
||||
<style>
|
||||
body, html, iframe { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
iframe { border: 1px solid black; background: gray; margin-left: 33px; margin-top: 24px; width: 400px; height: 400px; transform: scale(0.8); }
|
||||
</style>
|
||||
<iframe srcdoc="
|
||||
<style>
|
||||
body, html, iframe { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
iframe { border: 3px solid black; background: gray; margin-left: 18px; margin-top: 14px; width: 200px; height: 200px; transform: scale(0.7); }
|
||||
</style>
|
||||
<iframe srcdoc='
|
||||
<style>
|
||||
div { margin-left: 10px; margin-top: 20px; width: 2px; height: 2px; }
|
||||
</style>
|
||||
<div>Target</div>
|
||||
'></iframe>
|
||||
"></iframe>
|
||||
`);
|
||||
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);
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user