test(e2e): drive the bottom sheet on WebKit with an iPhone profile

A webkit-iphone project runs one spec: drag, spring back, flick, scroll before drag, a slider sideways, the three closes, focus return, the page's scroll position and pull-to-refresh. CI installs WebKit beside Chromium.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 10:20:30 +02:00
1 parent 55211c0e69
commit 72e6fd6620
3 files changed
+504 -1

No files matched your search

+3 -1
View File
@@ -81,7 +81,9 @@ jobs:
restore-keys: |
playwright-${{ runner.os }}-
- run: pnpm exec playwright install --with-deps chromium
# WebKit for the one phone spec that needs Safari's engine
# (`bottom-sheet.spec.ts`, the `webkit-iphone` project).
- run: pnpm exec playwright install --with-deps chromium webkit
# `pnpm build` (not bare `next build`): the prebuild hook generates
# the versioned service-worker stamp AND the static i18n catalog
+485
View File
@@ -0,0 +1,485 @@
import type { Locator, Page } from "@playwright/test";
import { mockDay } from "./setup/day-mock";
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* The bottom sheet's drag on WebKit with an iPhone profile (the
* `webkit-iphone` project), against the three kinds of bottom sheet the app
* has: the More sheet (a plain `<Sheet side="bottom">`), the day sheet (the
* preview step) and a capture form (`ResponsiveSheet` on a phone).
*
* Playwright can tap on WebKit but not move a touch, so a drag is dispatched
* through the page as the browser would deliver it: `pointerdown` /
* `pointermove` / `pointerup` with `pointerType: "touch"` and the matching
* `touchstart` / `touchmove` / `touchend`, on the element under the finger,
* paced in real time so the release speed is real. Nothing calls into the
* sheet's own code. What synthetic events cannot do is scroll natively, so
* "content scrolls first" is proven as "a scrolled body does not move the
* sheet", with the scroll position set the way a scroll would leave it.
*
* Stable hooks only: `bottom-nav-more` / `bottom-nav-more-sheet`,
* `day-panel` with `data-sheet-snap`, `day-body`, `day-footer`,
* `sheet-grip`, `sheet-close-button`, `sheet-overlay`, `capture-picker-*`.
*/
interface Drag {
dx?: number;
dy: number;
/** Duration of the movement, ms. */
ms: number;
steps?: number;
/** Rest before lifting the finger, ms (a slow, deliberate release). */
holdMs?: number;
}
/** A touch drag from (x, y), dispatched as WebKit delivers one. */
async function touchDrag(page: Page, x: number, y: number, drag: Drag) {
await page.evaluate(
async ({ x, y, dx, dy, ms, steps, holdMs }) => {
const target = document.elementFromPoint(x, y);
if (!target) throw new Error(`nothing under ${x},${y}`);
const id = 11;
const touchAt = (cx: number, cy: number) => {
const init = {
identifier: id,
target,
clientX: cx,
clientY: cy,
pageX: cx,
pageY: cy,
screenX: cx,
screenY: cy,
radiusX: 4,
radiusY: 4,
force: 1,
};
try {
return new Touch(init);
} catch {
return init as unknown as Touch;
}
};
const touchEvent = (type: string, cx: number, cy: number) => {
const touch = touchAt(cx, cy);
const touches = type === "touchend" ? [] : [touch];
try {
return new TouchEvent(type, {
touches,
targetTouches: touches,
changedTouches: [touch],
bubbles: true,
cancelable: true,
composed: true,
});
} catch {
const event = new Event(type, {
bubbles: true,
cancelable: true,
composed: true,
});
Object.defineProperty(event, "touches", { value: touches });
Object.defineProperty(event, "targetTouches", { value: touches });
Object.defineProperty(event, "changedTouches", { value: [touch] });
return event;
}
};
const fire = (
pointer: "pointerdown" | "pointermove" | "pointerup",
cx: number,
cy: number,
) => {
target.dispatchEvent(
new PointerEvent(pointer, {
pointerId: id,
pointerType: "touch",
isPrimary: true,
clientX: cx,
clientY: cy,
buttons: pointer === "pointerup" ? 0 : 1,
bubbles: true,
cancelable: true,
composed: true,
}),
);
const touch = {
pointerdown: "touchstart",
pointermove: "touchmove",
pointerup: "touchend",
}[pointer];
target.dispatchEvent(touchEvent(touch, cx, cy));
};
const wait = (t: number) => new Promise((r) => setTimeout(r, t));
fire("pointerdown", x, y);
for (let i = 1; i <= steps; i += 1) {
await wait(ms / steps);
fire("pointermove", x + (dx * i) / steps, y + (dy * i) / steps);
}
if (holdMs) await wait(holdMs);
fire("pointerup", x + dx, y + dy);
},
{
x,
y,
dx: drag.dx ?? 0,
dy: drag.dy,
ms: drag.ms,
steps: drag.steps ?? Math.max(4, Math.round(drag.ms / 16)),
holdMs: drag.holdMs ?? 0,
},
);
}
/**
* The browser's pointer capture for a dispatched pointer. A real touch is
* captured by the browser; a dispatched one is not, and Radix's slider only
* follows a pointer it holds. This gives it the same bookkeeping, nothing
* more: events still go to the element they are dispatched on.
*/
async function emulateCapture(page: Page) {
await page.addInitScript(() => {
const held = new WeakMap<Element, Set<number>>();
Element.prototype.setPointerCapture = function (id: number) {
const set = held.get(this) ?? new Set<number>();
set.add(id);
held.set(this, set);
};
Element.prototype.hasPointerCapture = function (id: number) {
return held.get(this)?.has(id) ?? false;
};
Element.prototype.releasePointerCapture = function (id: number) {
held.get(this)?.delete(id);
};
});
}
async function centre(locator: Locator) {
const box = await locator.boundingBox();
if (!box) throw new Error("not on screen");
return { x: box.x + box.width / 2, y: box.y + box.height / 2, box };
}
async function topOf(locator: Locator) {
return (await locator.boundingBox())?.y ?? NaN;
}
/** Waits for the sheet to come to rest (open slide or settle finished). */
async function settled(locator: Locator) {
await expect
.poll(() =>
locator.evaluate((el) =>
el.getAnimations().every((a) => a.playState !== "running"),
),
)
.toBe(true);
await locator.page().waitForTimeout(300);
}
const more = (page: Page) =>
page.locator('[data-testid="bottom-nav-more-sheet"]');
async function openMore(page: Page) {
await page.locator('[data-testid="bottom-nav-more"]').tap();
await expect(more(page)).toBeVisible();
await settled(more(page));
}
function isoDaysAgo(days: number): string {
const d = new Date(Date.now() - days * 86_400_000);
return d.toISOString().slice(0, 10);
}
test.describe("the bottom sheet", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test.beforeEach(async ({ page, context }, testInfo) => {
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "en", url: baseURL },
]);
await mockDay(page);
});
test("a slow drag past a quarter of its height closes it", async ({
page,
}) => {
await page.goto("/mood");
await openMore(page);
const title = more(page).getByRole("heading").first();
const { x, y } = await centre(title);
const { box } = await centre(more(page));
await touchDrag(page, x, y, {
dy: box.height * 0.4,
ms: 700,
holdMs: 160,
});
await expect(more(page)).toHaveCount(0);
});
test("a short drag springs back", async ({ page }) => {
await page.goto("/mood");
await openMore(page);
await expect(more(page)).toBeVisible();
const before = await topOf(more(page));
const { x, y } = await centre(more(page).getByRole("heading").first());
// Dragging: the sheet follows the finger and the backdrop lightens.
const during = page.evaluate(
() =>
new Promise<{ moved: number; opacity: number }>((resolve) => {
setTimeout(() => {
const sheet = document.querySelector(
'[data-testid="bottom-nav-more-sheet"]',
) as HTMLElement;
const overlay = document.querySelector(
'[data-slot="sheet-overlay"]',
) as HTMLElement;
resolve({
moved: new DOMMatrix(getComputedStyle(sheet).transform).f,
opacity: Number(getComputedStyle(overlay).opacity),
});
}, 380);
}),
);
await touchDrag(page, x, y, { dy: 60, ms: 300, holdMs: 200 });
const seen = await during;
expect(seen.moved).toBeGreaterThan(40);
expect(seen.opacity).toBeLessThan(1);
await settled(more(page));
await expect(more(page)).toBeVisible();
expect(Math.abs((await topOf(more(page))) - before)).toBeLessThan(1);
});
test("a fast flick closes it", async ({ page }) => {
await page.goto("/mood");
await openMore(page);
const { x, y } = await centre(more(page).getByRole("heading").first());
const { box } = await centre(more(page));
// Well short of a quarter of the height, but fast.
await touchDrag(page, x, y, {
dy: Math.min(70, box.height * 0.2),
ms: 40,
steps: 4,
});
await expect(more(page)).toHaveCount(0);
});
test("the backdrop, the X and Escape close it; focus goes back to the trigger", async ({
page,
}) => {
await page.goto("/mood");
const trigger = page.locator('[data-testid="bottom-nav-more"]');
await openMore(page);
await page.touchscreen.tap(195, 40);
await expect(more(page)).toHaveCount(0);
await openMore(page);
const close = more(page).locator('[data-slot="sheet-close-button"]');
const closeBox = (await close.boundingBox())!;
expect(closeBox.width).toBeGreaterThanOrEqual(44);
expect(closeBox.height).toBeGreaterThanOrEqual(44);
await close.tap();
await expect(more(page)).toHaveCount(0);
// The keyboard path: open from the focused trigger, close with Escape.
await trigger.focus();
await page.keyboard.press("Enter");
await expect(more(page)).toBeVisible();
await expect(more(page)).toHaveAttribute("role", "dialog");
await expect(more(page)).toHaveAttribute("aria-modal", "true");
await expect(more(page)).toHaveAccessibleName(/more/i);
await expect
.poll(() =>
more(page).evaluate((el) => el.contains(document.activeElement)),
)
.toBe(true);
// Tab stays inside.
for (let i = 0; i < 14; i += 1) await page.keyboard.press("Tab");
expect(
await more(page).evaluate((el) => el.contains(document.activeElement)),
).toBe(true);
await page.keyboard.press("Escape");
await expect(more(page)).toHaveCount(0);
await expect(trigger).toBeFocused();
});
test("the page behind keeps its scroll position and is never pulled to refresh", async ({
page,
}) => {
await page.goto("/");
const main = page.locator("#main-content");
await expect(main).toBeVisible();
await page.waitForTimeout(800);
// Watch for the refresh disc for the rest of the test.
await page.evaluate(() => {
(window as unknown as { ptrSeen: number }).ptrSeen = 0;
new MutationObserver(() => {
if (document.querySelector('[data-slot="pull-to-refresh-indicator"]')) {
(window as unknown as { ptrSeen: number }).ptrSeen += 1;
}
}).observe(document.body, { childList: true, subtree: true });
});
const ptrSeen = () =>
page.evaluate(() => (window as unknown as { ptrSeen: number }).ptrSeen);
// The control: the same pull on the page itself does arm the disc, so
// the watch above can see it (short of the refresh threshold).
await touchDrag(page, 195, 320, { dy: 90, ms: 400, holdMs: 120 });
await expect.poll(ptrSeen).toBeGreaterThan(0);
await page.waitForTimeout(300);
await page.evaluate(() => {
(window as unknown as { ptrSeen: number }).ptrSeen = 0;
});
// A pull on the sheet, short (it springs back) and long (it closes),
// from the dashboard at the top: the page behind never takes it.
await openMore(page);
const { x, y } = await centre(more(page).getByRole("heading").first());
await touchDrag(page, x, y, { dy: 60, ms: 400, holdMs: 150 });
await settled(more(page));
const { box } = await centre(more(page));
await touchDrag(page, x, y, { dy: box.height * 0.5, ms: 600, holdMs: 150 });
await expect(more(page)).toHaveCount(0);
expect(await ptrSeen()).toBe(0);
// Scrolled down, the page is where it was after the sheet has gone.
await main.evaluate((el) => el.scrollTo({ top: 400 }));
const before = await main.evaluate((el) => el.scrollTop);
expect(before).toBeGreaterThan(0);
await openMore(page);
const at = await centre(more(page).getByRole("heading").first());
const sheetBox = (await more(page).boundingBox())!;
await touchDrag(page, at.x, at.y, {
dy: sheetBox.height * 0.5,
ms: 600,
holdMs: 150,
});
await expect(more(page)).toHaveCount(0);
expect(await main.evaluate((el) => el.scrollTop)).toBe(before);
expect(await ptrSeen()).toBe(0);
});
test("the day opens on its preview step, rises on a pull up, and its content scrolls before it drags", async ({
page,
}) => {
// A short window, so the day's content is longer than its sheet on
// both steps and the body really scrolls.
await page.setViewportSize({ width: 390, height: 480 });
await page.goto(`/mood?day=${isoDaysAgo(3)}`);
const panel = page.locator('[data-slot="day-panel"]');
await expect(panel).toBeVisible();
await expect(panel).toHaveAttribute("data-shell", "bottom");
await expect(panel).toHaveAttribute("data-sheet-snap", "preview");
await settled(panel);
const viewport = page.viewportSize()!;
// On the preview step the footer stands on the window's bottom edge.
const footer = panel.locator('[data-slot="day-footer"]');
const footerBox = (await footer.boundingBox())!;
expect(
Math.abs(footerBox.y + footerBox.height - viewport.height),
).toBeLessThan(2);
// A pull up on the content raises it to the full 90 dvh.
const body = panel.locator('[data-slot="day-body"]');
const b = await centre(body);
await touchDrag(page, b.x, b.y, { dy: -180, ms: 500, holdMs: 150 });
await expect(panel).toHaveAttribute("data-sheet-snap", "full");
await settled(panel);
expect(await topOf(panel)).toBeCloseTo(viewport.height * 0.1, -1);
// Scrolled content: a pull down scrolls (the browser's), the sheet stays.
const scrollable = await body.evaluate(
(el) => el.scrollHeight > el.clientHeight,
);
expect(scrollable).toBe(true);
await body.evaluate((el) => el.scrollTo({ top: 60 }));
const top = await topOf(panel);
const inner = await centre(body);
await touchDrag(page, inner.x, inner.y, {
dy: 260,
ms: 500,
holdMs: 150,
});
await settled(panel);
await expect(panel).toHaveAttribute("data-sheet-snap", "full");
expect(Math.abs((await topOf(panel)) - top)).toBeLessThan(1);
// At its very top the same pull drags the sheet: down to the preview
// step, and from there out.
await body.evaluate((el) => el.scrollTo({ top: 0 }));
await touchDrag(page, inner.x, inner.y - 60, {
dy: 150,
ms: 500,
holdMs: 150,
});
await expect(panel).toHaveAttribute("data-sheet-snap", "preview");
await settled(panel);
const grip = panel.locator('[data-slot="sheet-grip"]');
const g = await centre(grip);
await touchDrag(page, g.x, g.y, { dy: 300, ms: 600, holdMs: 150 });
await expect(panel).toHaveCount(0);
await expect(page).toHaveURL(/\/mood$/);
});
test("the grip is a button that switches the day's two heights", async ({
page,
}) => {
await page.goto(`/mood?day=${isoDaysAgo(3)}`);
const panel = page.locator('[data-slot="day-panel"]');
await expect(panel).toHaveAttribute("data-sheet-snap", "preview");
const grip = panel.locator('[data-slot="sheet-grip"]');
await expect(grip).toHaveAttribute("aria-expanded", "false");
await grip.tap();
await expect(panel).toHaveAttribute("data-sheet-snap", "full");
await expect(grip).toHaveAttribute("aria-expanded", "true");
});
test("a slider inside a sheet moves sideways and the sheet stays", async ({
page,
}) => {
await emulateCapture(page);
await page.goto("/mood");
await page.locator('[data-testid="bottom-nav-capture"]').tap();
await page.locator('[data-testid="capture-picker-symptom"]').tap();
const form = page.locator(
'[data-slot="responsive-sheet-content"][data-state="open"]',
);
await expect(form).toBeVisible();
await settled(form);
const slider = form.locator('[data-slot="slider"]').first();
await slider.scrollIntoViewIfNeeded();
await settled(form);
const top = await topOf(form);
const s = await centre(slider);
const thumb = form.locator('[role="slider"]').first();
const valueOf = async () =>
(await thumb.count()) ? thumb.getAttribute("aria-valuenow") : null;
const before = await valueOf();
// A sideways drag with a little vertical wobble, across the track.
await touchDrag(page, s.box.x + 8, s.y, {
dx: s.box.width * 0.7,
dy: 6,
ms: 400,
});
await expect.poll(valueOf).not.toBe(before);
await settled(form);
await expect(form).toBeVisible();
expect(Math.abs((await topOf(form)) - top)).toBeLessThan(1);
});
test("under reduced motion it opens without a slide", async ({ page }) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/mood");
await page.locator('[data-testid="bottom-nav-more"]').tap();
await expect(more(page)).toBeVisible();
expect(
await more(page).evaluate(
(el) =>
el.getAnimations().filter((a) => a.playState === "running").length,
),
).toBe(0);
});
});
+16
View File
@@ -113,6 +113,8 @@ export default defineConfig({
testIgnore: [
"v137-record-session-fence-offline.spec.ts",
"pwa-offline.spec.ts",
// Runs only in the WebKit phone project.
"bottom-sheet.spec.ts",
],
use: {
...devices["Desktop Chrome"],
@@ -228,6 +230,8 @@ export default defineConfig({
// Runs only in the service-worker project.
"v137-record-session-fence-offline.spec.ts",
"pwa-offline.spec.ts",
// Runs only in the WebKit phone project.
"bottom-sheet.spec.ts",
// The Apple Health import journey uploads an archive into the one
// shared account and its refusal control asserts that the account's
// Apple-Health row count did not move. A second project running the
@@ -304,6 +308,18 @@ export default defineConfig({
colorScheme: "dark",
},
},
{
// The bottom sheet's drag, on the engine it is for: iOS Safari is
// WebKit, and Chromium's touch scrolling and pointer capture behave
// differently enough that a pass there proves little about an iPhone.
// One spec only, so the extra browser costs the shards seconds.
name: "webkit-iphone",
testMatch: ["bottom-sheet.spec.ts"],
use: {
...devices["iPhone 13"],
colorScheme: "dark",
},
},
],
// Spin up the production build for E2E. `pnpm build` is run