From 72e6fd66201cd36e09a152b2e71f7719992e65ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 10:20:30 +0200 Subject: [PATCH] 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. --- .github/workflows/e2e.yml | 4 +- e2e/bottom-sheet.spec.ts | 485 ++++++++++++++++++++++++++++++++++++++ playwright.config.ts | 16 ++ 3 files changed, 504 insertions(+), 1 deletion(-) create mode 100644 e2e/bottom-sheet.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 4ac0fc739..5a21adfa6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/e2e/bottom-sheet.spec.ts b/e2e/bottom-sheet.spec.ts new file mode 100644 index 000000000..0e5d958f8 --- /dev/null +++ b/e2e/bottom-sheet.spec.ts @@ -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 ``), 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.prototype.setPointerCapture = function (id: number) { + const set = held.get(this) ?? new Set(); + 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); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index b6cedfe56..969480f97 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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