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:
Dmitry Gozman 2022-11-18 16:51:39 -08:00 committed by GitHub
parent b1e2b8b629
commit 941090f0c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 186 additions and 10 deletions

View File

@ -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()!;
}

View File

@ -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 -

View File

@ -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');
});

View File

@ -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);
});