mirror of
https://github.com/microsoft/playwright.git
synced 2025-01-07 11:46:42 +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;
|
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;
|
let frame = this._frame;
|
||||||
const data: { frame: frames.Frame, frameElement: ElementHandle<Element> | null, pointInFrame: types.Point }[] = [];
|
const data: { frame: frames.Frame, frameElement: ElementHandle<Element> | null, pointInFrame: types.Point }[] = [];
|
||||||
while (frame.parentFrame()) {
|
while (frame.parentFrame()) {
|
||||||
const frameElement = await frame.frameElement() as ElementHandle<Element>;
|
const frameElement = await frame.frameElement() as ElementHandle<Element>;
|
||||||
const box = await frameElement.boundingBox();
|
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';
|
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.
|
// 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 });
|
data.push({ frame, frameElement, pointInFrame });
|
||||||
frame = frame.parentFrame()!;
|
frame = frame.parentFrame()!;
|
||||||
}
|
}
|
||||||
|
@ -507,6 +507,18 @@ export class InjectedScript {
|
|||||||
return { left: parseInt(style.borderLeftWidth || '', 10), top: parseInt(style.borderTopWidth || '', 10) };
|
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 {
|
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;
|
let element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
|
||||||
if (!element)
|
if (!element)
|
||||||
@ -901,16 +913,18 @@ export class InjectedScript {
|
|||||||
// 2k. (injected) Event interceptor is removed.
|
// 2k. (injected) Event interceptor is removed.
|
||||||
// 2l. All navigations triggered between 2g-2k are awaited to be either committed or canceled.
|
// 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.
|
// 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');
|
const element = this.retarget(node, 'button-link');
|
||||||
if (!element || !element.isConnected)
|
if (!element || !element.isConnected)
|
||||||
return 'error:notconnected';
|
return 'error:notconnected';
|
||||||
|
|
||||||
// First do a preliminary check, to reduce the possibility of some iframe
|
if (hitPoint) {
|
||||||
// intercepting the action.
|
// First do a preliminary check, to reduce the possibility of some iframe
|
||||||
const preliminaryResult = this.expectHitTarget(hitPoint, element);
|
// intercepting the action.
|
||||||
if (preliminaryResult !== 'done')
|
const preliminaryResult = this.expectHitTarget(hitPoint, element);
|
||||||
return preliminaryResult.hitTargetDescription;
|
if (preliminaryResult !== 'done')
|
||||||
|
return preliminaryResult.hitTargetDescription;
|
||||||
|
}
|
||||||
|
|
||||||
// When dropping, the "element that is being dragged" often stays under the cursor,
|
// 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 -
|
// 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);
|
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`);
|
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 }) => {
|
it('should click button inside frameset', async ({ page, server }) => {
|
||||||
await page.goto(server.PREFIX + '/frames/frameset.html');
|
await page.goto(server.PREFIX + '/frames/frameset.html');
|
||||||
const frameElement = await page.$('frame');
|
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();
|
const frame = await frameElement.contentFrame();
|
||||||
await frame.click('button');
|
await frame.click('button');
|
||||||
expect(await frame.evaluate('result')).toBe('Clicked');
|
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');
|
await page.click('#inner');
|
||||||
expect(await page.evaluate('__CLICKED')).toBe(true);
|
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