playwright/tests/tap.spec.ts
Dmitry Gozman 61ff52704c
feat(input): perform hit target check during input (#9546)
This replaces previous `checkHitTarget` heuristic that took place before the action
with a new `setupHitTargetInterceptor` that works during the action:
- Before the action we set up capturing listeners on the window.
- During the action we ensure that event target is the element we expect to interact with.
- After the action we clear the listeners.

This should catch the "layout shift" issues where things move
between action point calculation and the actual action.

Possible issues:
- **Risk:** `{ trial: true }` might dispatch move events like `mousemove` or `pointerout`,
because we do actually move the mouse but prevent all other events.
- **Timing**: The timing of "hit target check" has moved, so this may affect different web pages
in different ways, for example expose more races. In this case, we should retry the click as before.
- **No risk**: There is still a possibility of mis-targeting with iframes shifting around,
because we only intercept in the target frame. This behavior does not change.

There is an opt-out environment variable PLAYWRIGHT_NO_LAYOUT_SHIFT_CHECK that reverts to previous behavior.
2021-11-05 17:31:28 -07:00

214 lines
7.5 KiB
TypeScript

/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { contextTest as it, expect } from './config/browserTest';
import { ElementHandle } from 'playwright-core';
import type { ServerResponse } from 'http';
it.use({ hasTouch: true });
it('should send all of the correct events', async ({ page }) => {
await page.setContent(`
<div id="a" style="background: lightblue; width: 50px; height: 50px">a</div>
<div id="b" style="background: pink; width: 50px; height: 50px">b</div>
`);
await page.tap('#a');
const eventsHandle = await trackEvents(await page.$('#b'));
await page.tap('#b');
// webkit doesnt send pointerenter or pointerleave or mouseout
expect(await eventsHandle.jsonValue()).toEqual([
'pointerover', 'pointerenter',
'pointerdown', 'touchstart',
'pointerup', 'pointerout',
'pointerleave', 'touchend',
'mouseover', 'mouseenter',
'mousemove', 'mousedown',
'mouseup', 'click',
]);
});
it('trial run should not tap', async ({ page }) => {
await page.setContent(`
<div id="a" style="background: lightblue; width: 50px; height: 50px">a</div>
<div id="b" style="background: pink; width: 50px; height: 50px">b</div>
`);
await page.tap('#a');
const eventsHandle = await trackEvents(await page.$('#b'));
await page.tap('#b', { trial: true });
const expected = process.env.PLAYWRIGHT_NO_LAYOUT_SHIFT_CHECK ? [] : ['pointerover', 'pointerenter', 'pointerout', 'pointerleave'];
expect(await eventsHandle.jsonValue()).toEqual(expected);
});
it('should not send mouse events touchstart is canceled', async ({ page }) => {
await page.setContent(`<div style="width: 50px; height: 50px; background: red">`);
await page.evaluate(() => {
// touchstart is not cancelable unless passive is false
document.addEventListener('touchstart', t => t.preventDefault(), { passive: false });
});
const eventsHandle = await trackEvents(await page.$('div'));
await page.tap('div');
expect(await eventsHandle.jsonValue()).toEqual([
'pointerover', 'pointerenter',
'pointerdown', 'touchstart',
'pointerup', 'pointerout',
'pointerleave', 'touchend',
]);
});
it('should not send mouse events when touchend is canceled', async ({ page }) => {
await page.setContent(`<div style="width: 50px; height: 50px; background: red">`);
await page.evaluate(() => {
document.addEventListener('touchend', t => t.preventDefault());
});
const eventsHandle = await trackEvents(await page.$('div'));
await page.tap('div');
expect(await eventsHandle.jsonValue()).toEqual([
'pointerover', 'pointerenter',
'pointerdown', 'touchstart',
'pointerup', 'pointerout',
'pointerleave', 'touchend',
]);
});
it('should wait for a navigation caused by a tap', async ({ page, server }) => {
await page.goto(server.EMPTY_PAGE);
await page.setContent(`
<a href="/intercept-this.html">link</a>;
`);
const responsePromise = new Promise<ServerResponse>(resolve => {
server.setRoute('/intercept-this.html', (handler, response) => {
resolve(response);
});
});
let resolved = false;
const tapPromise = page.tap('a').then(() => resolved = true);
const response = await responsePromise;
// make sure the tap doesnt resolve too early
await new Promise(x => setTimeout(x, 100));
expect(resolved).toBe(false);
response.end('foo');
await tapPromise;
expect(resolved).toBe(true);
});
it('should work with modifiers', async ({ page }) => {
await page.setContent('hello world');
const altKeyPromise = page.evaluate(() => new Promise(resolve => {
document.addEventListener('touchstart', event => {
resolve(event.altKey);
}, { passive: false });
}));
// make sure the evals hit the page
await page.evaluate(() => void 0);
await page.tap('body', {
modifiers: ['Alt']
});
expect(await altKeyPromise).toBe(true);
});
it('should send well formed touch points', async ({ page }) => {
const promises = Promise.all([
page.evaluate(() => new Promise(resolve => {
document.addEventListener('touchstart', event => {
resolve([...event.touches].map(t => ({
identifier: t.identifier,
clientX: t.clientX,
clientY: t.clientY,
pageX: t.pageX,
pageY: t.pageY,
radiusX: 'radiusX' in t ? t.radiusX : t['webkitRadiusX'],
radiusY: 'radiusY' in t ? t.radiusY : t['webkitRadiusY'],
rotationAngle: 'rotationAngle' in t ? t.rotationAngle : t['webkitRotationAngle'],
force: 'force' in t ? t.force : t['webkitForce'],
})));
}, false);
})),
page.evaluate(() => new Promise(resolve => {
document.addEventListener('touchend', event => {
resolve([...event.touches].map(t => ({
identifier: t.identifier,
clientX: t.clientX,
clientY: t.clientY,
pageX: t.pageX,
pageY: t.pageY,
radiusX: 'radiusX' in t ? t.radiusX : t['webkitRadiusX'],
radiusY: 'radiusY' in t ? t.radiusY : t['webkitRadiusY'],
rotationAngle: 'rotationAngle' in t ? t.rotationAngle : t['webkitRotationAngle'],
force: 'force' in t ? t.force : t['webkitForce'],
})));
}, false);
})),
]);
// make sure the evals hit the page
await page.evaluate(() => void 0);
await page.touchscreen.tap(40, 60);
const [touchstart, touchend] = await promises;
expect(touchstart).toEqual([{
clientX: 40,
clientY: 60,
force: 1,
identifier: 0,
pageX: 40,
pageY: 60,
radiusX: 1,
radiusY: 1,
rotationAngle: 0,
}]);
expect(touchend).toEqual([]);
});
it('should wait until an element is visible to tap it', async ({ page }) => {
const div = await page.evaluateHandle(() => {
const button = document.createElement('button');
button.textContent = 'not clicked';
document.body.appendChild(button);
button.style.display = 'none';
return button;
});
const tapPromise = div.tap();
await div.evaluate(div => div.onclick = () => div.textContent = 'clicked');
await div.evaluate(div => div.style.display = 'block');
await tapPromise;
expect(await div.textContent()).toBe('clicked');
});
async function trackEvents(target: ElementHandle) {
const eventsHandle = await target.evaluateHandle(target => {
const events: string[] = [];
for (const event of [
'mousedown', 'mouseenter', 'mouseleave', 'mousemove', 'mouseout', 'mouseover', 'mouseup', 'click',
'pointercancel', 'pointerdown', 'pointerenter', 'pointerleave', 'pointermove', 'pointerout', 'pointerover', 'pointerup',
'touchstart', 'touchend', 'touchmove', 'touchcancel',
])
target.addEventListener(event, () => events.push(event), false);
return events;
});
return eventsHandle;
}
it.describe('locators', () => {
it('should send all of the correct events', async ({ page }) => {
await page.setContent(`
<div id="a" style="background: lightblue; width: 50px; height: 50px">a</div>
<div id="b" style="background: pink; width: 50px; height: 50px">b</div>
`);
await page.locator('#a').tap();
await page.locator('#b').tap();
});
});