test(many): gestures flakiness (#27808)

Issue number: multiple internals

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

Multiple tests that use gestures are flaky on GitHub. Due to that those
tests are being skipped.

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

- `page.mouse.move` will not work as expected if it the mouse moves
outside of the viewport. This may lead to events to not fire every time.
There's now a check to determine if the coordinates are valid. If they
are not, then it will update the coordinates to be as close to the
viewport's edge instead of being outside.
- Safari doesn't repaint the frame as often as the other browsers. This
causes the tests on GitHub to appear to be lagging. Now the frame is
forced to repaint only for Safari.
- Most tests are no longer being skipped.
- Range is still having issues on GitHub. It is no longer flaky locally
with the changes in this PR. I've had to revert them back to skip until
further notice.

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!-- If this introduces a breaking change, please describe the impact
and migration path for existing applications below. -->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

If this PR is merged, then:
- FW-3006, FW-2795, and FW-3079 can be closed
- FW-4556 still needs to remain open since range is still flaky on
GitHub

---------

Co-authored-by: ionitron <hi@ionicframework.com>
Co-authored-by: Amanda Johnston <90629384+amandaejohnston@users.noreply.github.com>
This commit is contained in:
Maria Hutt
2023-08-14 15:12:04 -05:00
committed by GitHub
parent 92b13c298b
commit eafa7b5dc6
20 changed files with 138 additions and 94 deletions

View File

@ -3,7 +3,6 @@ import { configs, dragElementBy, test } from '@utils/test/playwright';
import { testSlidingItem } from '../test.utils';
// TODO FW-3006
/**
* item-sliding doesn't have mode-specific styling
*/
@ -18,19 +17,12 @@ configs({ modes: ['md'] }).forEach(({ title, screenshot, config }) => {
});
});
// TODO FW-3006
/**
* This behavior does not vary across modes/directions
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('item-sliding: basic'), () => {
// mouse gesture is flaky on CI, skip for now
test.fixme('should open when swiped', async ({ page, skip }) => {
skip.browser(
(browserName: string) => browserName !== 'chromium',
'dragElementBy is flaky outside of Chrome browsers.'
);
test('should open when swiped', async ({ page }) => {
await page.goto(`/src/components/item-sliding/test/basic`, config);
const item = page.locator('#item2');
@ -41,7 +33,7 @@ configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, screenshot, co
await expect(item).toHaveScreenshot(screenshot(`item-sliding-gesture`));
});
test.skip('should not scroll when the item-sliding is swiped', async ({ page, skip }) => {
test('should not scroll when the item-sliding is swiped', async ({ page, skip }) => {
skip.browser('webkit', 'mouse.wheel is not available in WebKit');
await page.goto(`/src/components/item-sliding/test/basic`, config);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@ -73,7 +73,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
await ionModalDidPresent.next();
const modalHeader = page.locator('#modal-header');
await dragElementBy(modalHeader, page, 0, -500);
await dragElementBy(modalHeader, page, 0, 30);
const modal = page.locator('ion-modal');
expect(modal).not.toBe(null);

View File

@ -9,12 +9,7 @@ import { CardModalPage } from '../fixtures';
configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('card modal - nav'), () => {
let cardModalPage: CardModalPage;
test.beforeEach(async ({ page, skip }) => {
skip.browser(
(browserName: string) => browserName !== 'chromium',
'dragElementBy is flaky outside of Chrome browsers.'
);
test.beforeEach(async ({ page }) => {
cardModalPage = new CardModalPage(page);
await cardModalPage.navigate('/src/components/modal/test/card-nav?ionic:_testing=false', config);
});
@ -33,7 +28,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
const content = page.locator('.page-two-content');
await dragElementBy(content, page, 1000, 0, 10);
await dragElementBy(content, page, 370, 0, 10);
await ionNavDidChange.next();
});
@ -47,7 +42,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
await ionNavDidChange.next();
await cardModalPage.swipeToCloseModal('ion-modal ion-content.page-two-content');
await cardModalPage.swipeToCloseModal('ion-modal ion-content.page-two-content', true, 270);
});
});
});

View File

@ -15,7 +15,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
const modal = page.locator('ion-modal');
const content = (await page.$('ion-modal ion-content'))!;
await dragElementBy(content, page, 0, 500);
await dragElementBy(content, page, 0, 300);
await content.waitForElementState('stable');

View File

@ -30,7 +30,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
await content.evaluate((el: HTMLElement) => (el.scrollTop = 500));
await dragElementBy(content, page, 0, 500);
await dragElementBy(content, page, 0, 300);
await content.waitForElementState('stable');

View File

@ -61,12 +61,12 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
test('it should not swipe to close when swiped on the content but the content is scrolled', async ({ page }) => {
const modal = await cardModalPage.openModalByTrigger('#card');
const content = (await page.$('ion-modal ion-content'))!;
const content = page.locator('ion-modal ion-content');
await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0));
await cardModalPage.swipeToCloseModal('ion-modal ion-content', false);
await content.waitForElementState('stable');
await content.waitFor();
await expect(modal).toBeVisible();
});
test('it should not swipe to close when swiped on the content but the content is scrolled even when content is replaced', async ({
@ -76,12 +76,12 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
await page.click('ion-button.replace');
const content = (await page.$('ion-modal ion-content'))!;
const content = page.locator('ion-modal ion-content');
await content.evaluate((el: HTMLIonContentElement) => el.scrollToBottom(0));
await cardModalPage.swipeToCloseModal('ion-modal ion-content', false);
await content.waitForElementState('stable');
await content.waitFor();
await expect(modal).toBeVisible();
});
test('content should be scrollable after gesture ends', async ({ page }) => {

View File

@ -16,7 +16,7 @@ export class CardModalPage {
this.ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
}
async openModalByTrigger(selector: string) {
await this.page.click(selector);
await this.page.locator(selector).click();
await this.ionModalDidPresent.next();
return this.page.locator('ion-modal');

View File

@ -129,7 +129,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
const ionBreakpointDidChange = await page.spyOnEvent('ionBreakpointDidChange');
const header = page.locator('.modal-sheet ion-header');
await dragElementBy(header, page, 0, 150);
await dragElementBy(header, page, 0, 125);
await ionBreakpointDidChange.next();

View File

@ -77,13 +77,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
expect(await scrollEl.evaluate((el: HTMLElement) => el.scrollTop)).toEqual(0);
const box = (await knobEl.boundingBox())!;
const centerX = box.x + box.width / 2;
const centerY = box.y + box.height / 2;
await page.mouse.move(centerX, centerY);
await page.mouse.down();
await page.mouse.move(centerX + 30, centerY);
await dragElementBy(knobEl, page, 30, 0, undefined, undefined, false);
/**
* Do not use scrollToBottom() or other scrolling methods

View File

@ -10,15 +10,28 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
/**
* The mouse events are flaky on CI
*/
test.fixme('should emit start/end events', async ({ page }, testInfo) => {
await page.setContent(`<ion-range value="20"></ion-range>`, config);
test.fixme('should emit start/end events', async ({ page }) => {
/**
* Requires padding to prevent the knob from being clipped.
* If it's clipped, then the value might be one off.
* For example, if the knob is clipped on the right, then the value
* will be 99 instead of 100.
*/
await page.setContent(
`
<div style="padding: 0 20px">
<ion-range value="20"></ion-range>
</div>
`,
config
);
const rangeStart = await page.spyOnEvent('ionKnobMoveStart');
const rangeEnd = await page.spyOnEvent('ionKnobMoveEnd');
const rangeEl = page.locator('ion-range');
await dragElementBy(rangeEl, page, testInfo.project.metadata.rtl ? -300 : 300, 0);
await dragElementBy(rangeEl, page, 300, 0);
await page.waitForChanges();
/**
@ -65,13 +78,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
expect(await scrollEl.evaluate((el: HTMLElement) => el.scrollTop)).toEqual(0);
const box = (await knobEl.boundingBox())!;
const centerX = box.x + box.width / 2;
const centerY = box.y + box.height / 2;
await page.mouse.move(centerX, centerY);
await page.mouse.down();
await page.mouse.move(centerX + 30, centerY);
await dragElementBy(knobEl, page, 30, 0, undefined, undefined, false);
/**
* Do not use scrollToBottom() or other scrolling methods
@ -118,13 +125,7 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
const rangeHandle = page.locator('ion-range .range-knob-handle');
const ionChangeSpy = await page.spyOnEvent('ionChange');
const boundingBox = await rangeHandle.boundingBox();
await rangeHandle.hover();
await page.mouse.down();
await page.mouse.move(boundingBox!.x + 100, boundingBox!.y);
await page.mouse.up();
await dragElementBy(rangeHandle, page, 100, 0);
await ionChangeSpy.next();
@ -169,11 +170,9 @@ configs({ modes: ['ios'], directions: ['ltr'] }).forEach(({ title, config }) =>
const rangeHandle = page.locator('ion-range .range-knob-handle');
const ionInputSpy = await page.spyOnEvent('ionInput');
const boundingBox = await rangeHandle.boundingBox();
await rangeHandle.hover();
await page.mouse.down();
await page.mouse.move(boundingBox!.x + 100, boundingBox!.y);
await dragElementBy(rangeHandle, page, 100, 0, undefined, undefined, false);
await ionInputSpy.next();

View File

@ -3,13 +3,11 @@ import { configs, test } from '@utils/test/playwright';
import { pullToRefresh } from '../test.utils';
// TODO FW-2795: Enable this test when touch events/gestures are better supported in Playwright
/**
* This behavior does not vary across directions.
*/
configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe.skip(title('refresher: basic'), () => {
test.describe(title('refresher: basic'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/refresher/test/basic', config);
});

View File

@ -3,12 +3,11 @@ import { configs, test } from '@utils/test/playwright';
import { pullToRefresh } from '../test.utils';
// TODO FW-2795: Enable this test when touch events/gestures are better supported in Playwright
/**
* This behavior does not vary across directions.
*/
configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe.skip(title('refresher: custom scroll target'), () => {
test.describe(title('refresher: custom scroll target'), () => {
test.beforeEach(async ({ page }) => {
await page.goto('/src/components/refresher/test/scroll-target', config);
});
@ -19,7 +18,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
expect(await items.count()).toBe(30);
await pullToRefresh(page, '#inner-scroll');
await pullToRefresh(page);
expect(await items.count()).toBe(60);
});
@ -39,7 +38,7 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
expect(await items.count()).toBe(30);
await pullToRefresh(page, '#inner-scroll');
await pullToRefresh(page);
expect(await items.count()).toBe(60);
});

View File

@ -18,7 +18,7 @@ const pullToRefresh = async (page: E2EPage, selector = 'body') => {
const ev = await page.spyOnEvent('ionRefreshComplete');
await dragElementByYAxis(target, page, 400);
await dragElementByYAxis(target, page, 320);
await ev.next();
};

View File

@ -1,18 +1,12 @@
import { expect } from '@playwright/test';
import { configs, test, dragElementBy } from '@utils/test/playwright';
// TODO FW-3079
/**
* Reorder group does not have per-mode styles
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe.skip(title('reorder group: interactive'), () => {
test.beforeEach(async ({ page, skip }) => {
skip.browser(
(browserName: string) => browserName !== 'chromium',
'dragElementBy is flaky outside of Chrome browsers.'
);
test.describe(title('reorder group: interactive'), () => {
test.beforeEach(async ({ page }) => {
await page.goto(`/src/components/reorder-group/test/interactive`, config);
});
test('should drag and drop when ion-reorder wraps ion-item', async ({ page }) => {

View File

@ -1,17 +1,12 @@
import { expect } from '@playwright/test';
import { configs, test, dragElementBy } from '@utils/test/playwright';
// TODO FW-3079
/**
* Reorder group does not have per-mode styles
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe.skip(title('reorder group: nested'), () => {
test.beforeEach(async ({ page, skip }) => {
skip.browser(
(browserName: string) => browserName !== 'chromium',
'dragElementBy is flaky outside of Chrome browsers.'
);
test.describe(title('reorder group: nested'), () => {
test.beforeEach(async ({ page }) => {
await page.goto(`/src/components/reorder-group/test/nested`, config);
});
test('should drag and drop when ion-reorder wraps ion-item', async ({ page }) => {

View File

@ -1,17 +1,12 @@
import { expect } from '@playwright/test';
import { configs, test, dragElementBy } from '@utils/test/playwright';
// TODO FW-3079
/**
* Reorder group does not have per-mode styles
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe.skip(title('reorder group: scroll-target'), () => {
test.beforeEach(async ({ page, skip }) => {
skip.browser(
(browserName: string) => browserName !== 'chromium',
'dragElementBy is flaky outside of Chrome browsers.'
);
test.describe(title('reorder group: scroll-target'), () => {
test.beforeEach(async ({ page }) => {
await page.goto(`/src/components/reorder-group/test/scroll-target`, config);
});
test('should drag and drop when ion-reorder wraps ion-item', async ({ page }) => {

View File

@ -1,3 +1,11 @@
/**
* The drag gesture will not operate as expected when the element is dragged outside of the viewport because the Mouse class does not fire events outside of the viewport.
*
* For example, if the mouse is moved outside of the viewport, then the `mouseup` event will not fire.
*
* See https://playwright.dev/docs/api/class-mouse#mouse-move for more information.
*/
import type { ElementHandle, Locator } from '@playwright/test';
import type { E2EPage } from './';
@ -8,7 +16,8 @@ export const dragElementBy = async (
dragByX = 0,
dragByY = 0,
startXCoord?: number,
startYCoord?: number
startYCoord?: number,
releaseDrag = true
) => {
const boundingBox = await el.boundingBox();
@ -21,14 +30,17 @@ export const dragElementBy = async (
const startX = startXCoord === undefined ? boundingBox.x + boundingBox.width / 2 : startXCoord;
const startY = startYCoord === undefined ? boundingBox.y + boundingBox.height / 2 : startYCoord;
const endX = startX + dragByX;
const endY = startY + dragByY;
// Navigate to the start position.
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(endX, endY, { steps: 10 });
// Drag the element.
await moveElement(page, startX, startY, dragByX, dragByY);
if (releaseDrag) {
await page.mouse.up();
}
};
/**
@ -55,12 +67,83 @@ export const dragElementByYAxis = async (
const startX = boundingBox.x + boundingBox.width / 2;
const startY = startYCoord === undefined ? boundingBox.y + boundingBox.height / 2 : startYCoord;
// Navigate to the start position.
await page.mouse.move(startX, startY);
await page.mouse.down();
for (let i = 0; i < dragByY; i += 20) {
await page.mouse.move(startX, startY + i);
}
// Drag the element.
await moveElement(page, startX, startY, 0, dragByY);
await page.mouse.up();
};
const validateDragByX = (startX: number, dragByX: number, viewportWidth: number) => {
const endX = startX + dragByX;
// The element is being dragged past the right of the viewport.
if (endX > viewportWidth) {
const recommendedDragByX = viewportWidth - startX - 5;
throw new Error(
`The element is being dragged past the right of the viewport. Update the dragByX value to prevent going out of bounds. A recommended value is ${recommendedDragByX}.`
);
}
// The element is being dragged past the left of the viewport.
if (endX < 0) {
const recommendedDragByX = startX - 5;
throw new Error(
`The element is being dragged past the left of the viewport. Update the dragByX value to prevent going out of bounds. A recommended value is ${recommendedDragByX}.`
);
}
};
const validateDragByY = (startY: number, dragByY: number, viewportHeight: number) => {
const endY = startY + dragByY;
// The element is being dragged past the bottom of the viewport.
if (endY > viewportHeight) {
const recommendedDragByY = viewportHeight - startY - 5;
throw new Error(
`The element is being dragged past the bottom of the viewport. Update the dragByY value to prevent going out of bounds. A recommended value is ${recommendedDragByY}.`
);
}
// The element is being dragged past the top of the viewport.
if (endY < 0) {
const recommendedDragByY = startY - 5;
throw new Error(
`The element is being dragged past the top of the viewport. Update the dragByY value to prevent going out of bounds. A recommended value is ${recommendedDragByY}.`
);
}
};
const moveElement = async (page: E2EPage, startX: number, startY: number, dragByX = 0, dragByY = 0) => {
const steps = 10;
const browser = page.context().browser()!.browserType().name();
const viewport = page.viewportSize();
if (viewport === null) {
throw new Error(
'Cannot get viewport size. See https://playwright.dev/docs/api/class-page#page-viewport-size for more information'
);
}
validateDragByX(startX, dragByX, viewport.width);
validateDragByY(startY, dragByY, viewport.height);
const endX = startX + dragByX;
const endY = startY + dragByY;
// Drag the element.
for (let i = 1; i <= steps; i++) {
const middleX = startX + (endX - startX) * (i / steps);
const middleY = startY + (endY - startY) * (i / steps);
await page.mouse.move(middleX, middleY);
// Safari needs to wait for a repaint to occur before moving the mouse again.
if (browser === 'webkit' && i % 2 === 0) {
// Repainting every 2 steps is enough to keep the drag gesture smooth.
// Anything past 4 steps will cause the drag gesture to be flaky.
await page.evaluate(() => new Promise(requestAnimationFrame));
}
}
};