Skip to content

Commit

Permalink
Merge pull request #208 from inokawa/e2e-ios
Browse files Browse the repository at this point in the history
Enable e2e for Mobile Safari
  • Loading branch information
inokawa authored Nov 20, 2023
2 parents 076071b + cb73e85 commit 4869d97
Show file tree
Hide file tree
Showing 4 changed files with 321 additions and 9 deletions.
13 changes: 13 additions & 0 deletions .storybook/preview.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@
if (import.meta.env.STORYBOOK_RTL) {
document.documentElement.dir = "rtl";
}
// for e2e
if (import.meta.env.STORYBOOK_E2E) {
const style = document.createElement("style");
// elements with `pointer-events: none` can't be found by document.elementFromPoint()
style.appendChild(
document.createTextNode(
`* {
pointer-events: auto !important;
}`
)
);
document.head.appendChild(style);
}

export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
Expand Down
176 changes: 176 additions & 0 deletions e2e/VList.ios.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { test, expect } from "@playwright/test";
import {
storyUrl,
scrollableSelector,
getLastItem,
scrollWithTouch,
getScrollTop,
getFirstItem,
} from "./utils";

test.describe("check if scroll jump compensation in emulated iOS WebKit works", () => {
test("scroll with touch", async ({ page }) => {
await page.goto(storyUrl("basics-vlist--default"));

const component = await page.waitForSelector(scrollableSelector);
await component.waitForElementState("stable");

// check if first is displayed
const last = await getFirstItem(component);
await expect(last.text).toEqual("0");
await expect(last.top).toBeLessThanOrEqual(0);

await component.tap();

const [w, h] = await page.evaluate(() => [
window.outerWidth,
window.outerHeight,
]);
const centerX = w / 2;
const centerY = h / 2;

let top: number = await getScrollTop(component);
for (let i = 0; i < 5; i++) {
await scrollWithTouch(component, {
fromX: centerX,
fromY: centerY + h / 3,
toX: centerX,
toY: centerY - h / 3,
});

// check if item position is preserved during flush
const [/*nextTopBeforeFlush,*/ nextLastItemBeforeFlush] =
await Promise.all([
// getScrollTop(component),
getFirstItem(component),
]);
await page.waitForTimeout(500);
const [nextTop, nextLastItem] = await Promise.all([
getScrollTop(component),
getFirstItem(component),
]);

expect(nextTop).toBeGreaterThan(top);
// expect(nextTop).not.toBe(nextTopBeforeFlush);
expect(nextLastItem.text).toEqual(nextLastItemBeforeFlush.text);
expect(
Math.abs(nextLastItem.top - nextLastItemBeforeFlush.top) // FIXME: may not be 0 in Safari
).toBeLessThanOrEqual(1);

top = nextTop;
}
});

test("reverse scroll with touch", async ({ page }) => {
await page.goto(storyUrl("basics-vlist--reverse"));

const component = await page.waitForSelector(scrollableSelector);
await component.waitForElementState("stable");

// FIXME this offset is needed only in ci for unknown reason
const opts = { y: 60 } as const;

// check if last is displayed
const last = await getLastItem(component, opts);
await expect(last.text).toEqual("999");
await expect(last.bottom).toBeLessThanOrEqual(1); // FIXME: may not be 0 in Safari

await component.tap();

const [w, h] = await page.evaluate(() => [
window.outerWidth,
window.outerHeight,
]);
const centerX = w / 2;
const centerY = h / 2;

let top: number = await getScrollTop(component);
for (let i = 0; i < 5; i++) {
await scrollWithTouch(component, {
fromX: centerX,
fromY: centerY - h / 3,
toX: centerX,
toY: centerY + h / 3,
});

// check if item position is preserved during flush
const [nextTopBeforeFlush, nextLastItemBeforeFlush] = await Promise.all([
getScrollTop(component),
getLastItem(component, opts),
]);
await page.waitForTimeout(500);
const [nextTop, nextLastItem] = await Promise.all([
getScrollTop(component),
getLastItem(component, opts),
]);

expect(nextTop).toBeLessThan(top);
expect(nextTop).not.toBe(nextTopBeforeFlush);
expect(nextLastItem.text).toEqual(nextLastItemBeforeFlush.text);
expect(
Math.abs(nextLastItem.bottom - nextLastItemBeforeFlush.bottom) // FIXME: may not be 0 in Safari
).toBeLessThanOrEqual(1);

top = nextTop;
}
});

test("reverse scroll with momentum scroll", async ({ page }) => {
await page.goto(storyUrl("basics-vlist--reverse"));

const component = await page.waitForSelector(scrollableSelector);
await component.waitForElementState("stable");

// FIXME this offset is needed only in ci for unknown reason
const opts = { y: 60 } as const;

// check if last is displayed
const last = await getLastItem(component, opts);
await expect(last.text).toEqual("999");
await expect(last.bottom).toBeLessThanOrEqual(1); // FIXME: may not be 0 in Safari

await component.tap();

const [w, h] = await page.evaluate(() => [
window.outerWidth,
window.outerHeight,
]);
const centerX = w / 2;
const centerY = h / 2;

let top: number = await getScrollTop(component);
for (let i = 0; i < 5; i++) {
await scrollWithTouch(component, {
fromX: centerX,
fromY: centerY - h / 3,
toX: centerX,
toY: centerY + h / 3,
momentumScroll: true,
});

// check if item position is preserved during flush
const [nextTopBeforeFlush, nextLastItemBeforeFlush] = await Promise.all([
getScrollTop(component),
getLastItem(component, opts),
]);
await page.waitForTimeout(500);
const [nextTop, nextLastItem] = await Promise.all([
getScrollTop(component),
getLastItem(component, opts),
]);

expect(nextTop).toBeLessThan(top);
expect(nextTop).not.toBe(nextTopBeforeFlush);
expect(nextLastItem.text).toEqual(nextLastItemBeforeFlush.text);
expect(
Math.abs(nextLastItem.bottom - nextLastItemBeforeFlush.bottom) // FIXME: may not be 0 in Safari
).toBeLessThanOrEqual(1);

top = nextTop;
}
});

// TODO shift/unshift

// TODO display none
});
123 changes: 119 additions & 4 deletions e2e/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,20 @@ export const getFirstItem = (
};

export const getLastItem = (
scrollable: ElementHandle<HTMLElement | SVGElement>
scrollable: ElementHandle<HTMLElement | SVGElement>,
offset: { x?: number; y?: number } = {}
) => {
return scrollable.evaluate((s) => {
return scrollable.evaluate((s, { x: offsetX = 2, y: offsetY = 2 }) => {
const rect = s.getBoundingClientRect();
const el = document.elementFromPoint(rect.left + 2, rect.bottom - 2)!;
const el = document.elementFromPoint(
rect.left + offsetX,
rect.bottom - offsetY
)!;
return {
text: el.textContent!,
bottom: el.getBoundingClientRect().bottom - rect.bottom,
};
});
}, offset);
};

// export const getFirstItemRtl = (
Expand Down Expand Up @@ -184,3 +188,114 @@ export const windowScrollToRight = async (
});
await scrollable.waitForElementState("stable");
};

export const scrollWithTouch = (
scrollable: ElementHandle<HTMLElement | SVGElement>,
target: {
fromX: number;
toX: number;
fromY: number;
toY: number;
momentumScroll?: boolean;
}
): Promise<void> => {
return scrollable.evaluate(
async (
e,
{ fromX, toX, fromY, toY, momentumScroll: isMomentumScrolling = false }
) => {
const diffY = fromY - toY;
const diffX = fromX - toX;

let count = 1;
const MAX_COUNT = 60;

const createTouchEvent = (
name: "touchstart" | "touchmove" | "touchend"
): TouchEvent => {
// const touchObj = new Touch({
// identifier: Date.now(),
// target: e,
// clientX: fromX,
// clientY: fromY,
// radiusX: 2.5,
// radiusY: 2.5,
// rotationAngle: 10,
// force: 0.5,
// });
return new TouchEvent(name, {
bubbles: true,
cancelable: true,
// touches: [touchObj],
// targetTouches: [],
// changedTouches: [],
});
};

const touchStart = () => {
e.dispatchEvent(createTouchEvent("touchstart"));
};
const touchEnd = () => {
e.dispatchEvent(createTouchEvent("touchend"));
};
const touchMove = () => {
setTimeout(() => {
e.dispatchEvent(createTouchEvent("touchmove"));
if (count > MAX_COUNT) {
// NOP
} else {
if (diffY) {
e.scrollTop += diffY / MAX_COUNT;
}
if (diffX) {
e.scrollLeft += diffX / MAX_COUNT;
}
}

count++;
}, 500 / MAX_COUNT);
};

e.addEventListener(
"touchstart",
() => {
touchMove();
},
{ once: true, passive: true }
);

let resolve: () => void;
let isScrollStarted = false;
let isScrollEnded = false;
const onScroll = () => {
if (isScrollEnded) return;

if (isMomentumScrolling && !isScrollStarted) {
touchEnd();
}
isScrollStarted = true;

if (count >= MAX_COUNT) {
isScrollEnded = true;
e.removeEventListener("scroll", onScroll);

if (isMomentumScrolling) {
resolve();
} else {
setTimeout(() => {
touchEnd();
resolve();
}, 500);
}
} else {
touchMove();
}
};
e.addEventListener("scroll", onScroll, { passive: true });

touchStart();
return new Promise<void>((r) => (resolve = r));
},
target
);
};
18 changes: 13 additions & 5 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { defineConfig, devices } from "@playwright/test";
*/
// require('dotenv').config();

const IOS_SPECS = /.ios.spec.ts$/;

/**
* See https://playwright.dev/docs/test-configuration.
*/
Expand All @@ -28,34 +30,40 @@ export default defineConfig({

/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",

// video: "on",
},

/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
testIgnore: IOS_SPECS,
},

{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
testIgnore: IOS_SPECS,
},

{
name: "webkit",
use: { ...devices["Desktop Safari"] },
testIgnore: IOS_SPECS,
},

/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
{
name: "Mobile Safari",
use: { ...devices["iPhone 13"] },
testMatch: IOS_SPECS,
},

/* Test against branded browsers. */
// {
Expand All @@ -70,7 +78,7 @@ export default defineConfig({

/* Run your local dev server before starting the tests */
webServer: {
command: "npm run storybook",
command: "STORYBOOK_E2E=1 npm run storybook",
url: "http://127.0.0.1:6006",
reuseExistingServer: !process.env.CI,
},
Expand Down

0 comments on commit 4869d97

Please sign in to comment.