mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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.
486 lines
17 KiB
TypeScript
486 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|