mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
test(e2e): walk the app shell at every tablet size
tablet-shell.spec.ts runs on WebKit with an iPad profile (webkit-tablet, in the WebKit job) and on Chromium with an Android tablet profile (chromium-tablet, in the shards), across iPad mini to iPad Pro 13 in both orientations, Safari's bars, Split View and Slide Over. It checks that the shell fills the window and leaves the document nothing to scroll, that every rail destination scrolls into view on top and takes a tap, that the scroll cue follows the overflow, that main reaches its last line clear of the bottom bar, that the page scrolls again after a sheet and a dialog close, and that a tab strip leaves the vertical pan to the page. ui-standards records the shell and rail rules.
This commit is contained in:
1 parent
22976337cb
commit
798747f987
4 files changed
+455
-7
No files matched your search
@@ -62,7 +62,7 @@ jobs:
|
||||
AUTH_RP_ORIGIN: http://localhost:3000
|
||||
SESSION_COOKIE_SECURE: "false"
|
||||
NODE_ENV: production
|
||||
# The WebKit spec runs in the `webkit` job below.
|
||||
# The WebKit specs run in the `webkit` job below.
|
||||
E2E_SKIP_WEBKIT: "1"
|
||||
|
||||
steps:
|
||||
@@ -146,8 +146,9 @@ jobs:
|
||||
if [ -z "$version" ]; then echo "playwright-core not found in pnpm-lock.yaml"; exit 1; fi
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The WebKit spec (`bottom-sheet.spec.ts`, project `webkit-iphone`), in the
|
||||
# same image as the shards.
|
||||
# The WebKit specs, in the same image as the shards: the bottom sheet on an
|
||||
# iPhone (`bottom-sheet.spec.ts`, project `webkit-iphone`) and the app shell
|
||||
# on an iPad (`tablet-shell.spec.ts`, project `webkit-tablet`).
|
||||
webkit:
|
||||
runs-on: ubuntu-24.04
|
||||
needs: playwright-version
|
||||
@@ -202,7 +203,7 @@ jobs:
|
||||
|
||||
- run: NODE_OPTIONS=--max-old-space-size=8192 pnpm build
|
||||
|
||||
- run: pnpm exec playwright test --project=webkit-iphone
|
||||
- run: pnpm exec playwright test --project=webkit-iphone --project=webkit-tablet
|
||||
env:
|
||||
E2E_BASE_URL: http://localhost:3000
|
||||
|
||||
|
||||
@@ -32,10 +32,10 @@ The gaps this section used to list are closed: `TileHeader` carries a `size="sm"
|
||||
|
||||
## 2. Spacing scale
|
||||
|
||||
> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper + `<md` bottom-nav clearance on `<main>`). Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `<main>`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`.
|
||||
> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper, `pb-24` in the desktop shell, + the phone shell's bottom-nav clearance on `<main>`). The shell itself takes the window's edges (`absolute inset-0`), never `h-dvh`, and `<main>` is the one vertical scroller: the document neither scrolls nor bounces under it. Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `<main>`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`.
|
||||
|
||||
- **Allowed steps:** `1 / 1.5 / 2 / 2.5 / 3 / 3.5 / 4 / 6 / 8 / 10`. `2.5` and `3.5` are allowed: the medication card's rhythm is built on them, and the in-between steps earn their keep in dense card interiors. `5` (20 px), as in `py-5` or `p-5`, is not allowed; replace it when you touch a file that still has it. `7` (28 px) is not allowed either: it was the old Settings and Admin `pl-7` body gutter, which is gone (§13).
|
||||
- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`layout/auth-shell.tsx:318`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`.
|
||||
- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`MAIN_CONTENT_WRAPPER_CLASS` in `layout/shell-metrics.ts`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6 shell-desktop:pb-24`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`.
|
||||
- **Page rhythm:** root of every page is `space-y-6`. Grouped card sections: `space-y-10` between groups, `space-y-4` inside a group (notifications/integrations pattern). Insights sub-pages join `space-y-6`.
|
||||
- **Left reading edge:** page-level text (H1, explainer, section headings) sits at the container edge. Only surfaces that paint a visible border/background may inset their content (`px-4 md:px-6`). A block without a border must not carry horizontal padding.
|
||||
- **Grids:** grids **of** cards/tiles = `gap-4`; value read-outs **inside** one card = `gap-x-3 gap-y-2`; form field grids = `gap-4`.
|
||||
@@ -148,7 +148,7 @@ Forbidden on any Insights card: a hand-rolled icon+title row, a card title centr
|
||||
|
||||
## 9. One scroll floor; sub-shells never re-declare bottom-space
|
||||
|
||||
Reinforces §2's "scroll height is shell-owned". Only `AuthShell` owns the scroll container's bottom padding and any viewport-height reserve: the `<main>` clears the mobile bottom-nav (`pb-[calc(4rem+safe-area)]`) and its wrapper clears the Coach FAB (`pb-20`). **No page, section, or sub-shell** adds a `min-h-[calc(100dvh-…)]` / `min-h-dvh` reserve or a `pb-*` bottom gutter inside the scrolling `<main>`; a nested reserve stacks on the shell budget and scrolls past the last card.
|
||||
Reinforces §2's "scroll height is shell-owned". Only `AuthShell` owns the scroll container's bottom padding and any viewport-height reserve: the `<main>` clears the mobile bottom-nav (`pb-[calc(4rem+safe-area)]`), or the home indicator in the desktop shell, and its wrapper clears the Coach FAB (`pb-20`, `pb-24` in the desktop shell). **No page, section, or sub-shell** adds a `min-h-[calc(100dvh-…)]` / `min-h-dvh` reserve or a `pb-*` bottom gutter inside the scrolling `<main>`; a nested reserve stacks on the shell budget and scrolls past the last card.
|
||||
|
||||
A two-column sub-shell that needs a "don't jump while loading" floor derives it from **one shared source**: `SUB_SHELL_GRID_FLOOR` (`src/components/layout/shell-metrics.ts`); applied to the **grid** (not a column), so the `1fr` content row stretches to fill without over-shooting. It never re-declares the floor per-shell (`AdminShell` drifting to a stale 12rem column reserve is exactly how this recurred). Guard: `e2e/settings-overscroll.spec.ts` covers Settings **and** Admin and measures against each element's content-box bottom, so a redundant sub-shell `pb-*` fails instead of inflating the allowed budget.
|
||||
|
||||
@@ -156,6 +156,8 @@ A two-column sub-shell that needs a "don't jump while loading" floor derives it
|
||||
|
||||
Settings + Notifications are **account utilities, not feature destinations**. They live ONLY in the user/avatar menu (mobile top-bar `DropdownMenu`; desktop sidebar avatar menu, with Settings additionally in the sidebar footer). The mobile "More" overflow hub carries **feature destinations only** (Measurements, Mood, Cycle, Labs, Coach, …), never Settings or Notifications. The hub is `mobileMoreHubDestinations()` = `visibleNavDestinations` minus the primary slots (`/`, `/medications`, `/insights`); it does **not** append the utility tail. `NAV_UTILITY_DESTINATIONS` (`visibleUtilityDestinations()`) feeds the sidebar footer + user menus, not the mobile overflow. Never surface a utility in two menus reachable from the same screen.
|
||||
|
||||
The rail's destination list is the one part of the rail that gives up height: the logo band, the footer group and the account row stay pinned, and the list scrolls between them (`overscroll-contain`, no native scrollbar, fades at an edge with more). A mouse gets the height-stepped row density; a finger keeps the 44 px rows, and at an edge with more destinations the rail shows a scroll cue (`sidebar-nav-scroll-cue`) that a tap follows. Guard: `e2e/tablet-shell.spec.ts`.
|
||||
|
||||
## 11. Inline action row; the PageHeader pattern
|
||||
|
||||
A title/sentence with an adjacent action is one row, not a wrapping stack: `flex items-start justify-between gap-3`, the text `min-w-0 flex-1` (wraps internally), the action `shrink-0` (stays beside the text, never pushed to its own line). This is the canonical `PageHeader` / `TileHeader` (`right` slot) shape; reuse it (e.g. the dashboard-hero verdict + CTA) instead of a one-off `flex-wrap` row whose `gap-y-*` drops the action below on a full sentence. Keep the mobile tap-target floor (`min-h-11 sm:min-h-9`).
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
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 app shell on a tablet, under a finger, at every iPad size in both
|
||||
* orientations: the rail reaches every destination, `<main>` scrolls to its
|
||||
* last line, and closing a sheet or a dialog hands the scroll back.
|
||||
*
|
||||
* Runs in `webkit-tablet` (iPad, the engine an iPad runs; CI's WebKit job)
|
||||
* and `chromium-tablet` (an Android tablet, in the Chromium shards). Both
|
||||
* report `pointer: coarse`, so the rail keeps its 44 px rows and its list
|
||||
* overflows in landscape, which is the case that broke.
|
||||
*
|
||||
* What a browser under test cannot show is the iPadOS toolbar moving the
|
||||
* visible height away from `100dvh`; `shell-viewport.test.ts` holds the
|
||||
* decisions that take that disagreement out, and this file measures that the
|
||||
* shell they produce fills the window exactly and leaves the document
|
||||
* nothing to scroll or bounce.
|
||||
*
|
||||
* Stable hooks only: `sidebar-nav-region`, `sidebar-nav-scroll-cue`,
|
||||
* `sidebar-footer`, `main-content-wrapper`, `bottom-nav`,
|
||||
* `bottom-nav-more` (test id), `command-palette-trigger`,
|
||||
* `command-palette-input`, `day-panel`.
|
||||
*/
|
||||
|
||||
/** iPad and Android tablet sizes, CSS px, with the browser's own bars where they take room. */
|
||||
const VIEWPORTS = [
|
||||
{ name: "iPad mini portrait", width: 744, height: 1133 },
|
||||
{ name: "iPad mini landscape", width: 1133, height: 744 },
|
||||
{ name: "iPad mini landscape, Safari bars", width: 1133, height: 670 },
|
||||
{ name: "iPad landscape, Safari bars", width: 1000, height: 690 },
|
||||
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
|
||||
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
|
||||
{ name: "iPad Pro 11 portrait", width: 834, height: 1194 },
|
||||
{ name: "iPad Pro 11 landscape", width: 1194, height: 834 },
|
||||
{ name: "iPad Pro 13 portrait", width: 1024, height: 1366 },
|
||||
{ name: "iPad Pro 13 landscape", width: 1366, height: 1024 },
|
||||
{ name: "Split View two thirds", width: 694, height: 1024 },
|
||||
{ name: "Split View half, landscape", width: 590, height: 820 },
|
||||
{ name: "Slide Over", width: 375, height: 1024 },
|
||||
{ name: "Android tablet landscape", width: 1138, height: 712 },
|
||||
] as const;
|
||||
|
||||
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
|
||||
|
||||
async function openDashboard(page: Page) {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expect(
|
||||
page.locator('#main-content [data-slot="main-content-wrapper"]'),
|
||||
).toBeVisible();
|
||||
// The module-gated destinations join the rail once `/api/auth/me` lands.
|
||||
await expect(page.locator('aside nav a[href="/medications"]')).toHaveCount(1);
|
||||
}
|
||||
|
||||
/** The shell fills the window exactly and the document has nothing to scroll. */
|
||||
async function expectShellPinned(page: Page) {
|
||||
const m = await page.evaluate(() => {
|
||||
const shell = document.querySelector<HTMLElement>(".shell-safe-area")!;
|
||||
const rect = shell.getBoundingClientRect();
|
||||
const root = document.scrollingElement!;
|
||||
const html = getComputedStyle(document.documentElement);
|
||||
return {
|
||||
top: rect.top,
|
||||
bottom: rect.bottom,
|
||||
left: rect.left,
|
||||
right: rect.right,
|
||||
innerHeight,
|
||||
innerWidth,
|
||||
docScroll: root.scrollHeight - root.clientHeight,
|
||||
overflowY: html.overflowY,
|
||||
overscroll: html.overscrollBehaviorY,
|
||||
};
|
||||
});
|
||||
expect(m.top).toBe(0);
|
||||
expect(m.left).toBe(0);
|
||||
expect(Math.abs(m.bottom - m.innerHeight)).toBeLessThanOrEqual(0.5);
|
||||
expect(Math.abs(m.right - m.innerWidth)).toBeLessThanOrEqual(0.5);
|
||||
expect(m.docScroll).toBeLessThanOrEqual(0);
|
||||
expect(m.overflowY).toBe("hidden");
|
||||
// No rubber band on the document: on iOS it held the next swipe.
|
||||
expect(m.overscroll).toBe("none");
|
||||
}
|
||||
|
||||
function railNav(page: Page): Locator {
|
||||
return page.locator('aside [data-slot="sidebar-nav-region"] > nav');
|
||||
}
|
||||
|
||||
/** Scrolls `<main>` to its end, waiting out content that grows on the way. */
|
||||
async function scrollMainToEnd(page: Page) {
|
||||
let last = -1;
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const height = await page.evaluate(() => {
|
||||
const main = document.getElementById("main-content")!;
|
||||
main.scrollTop = main.scrollHeight;
|
||||
return main.scrollHeight;
|
||||
});
|
||||
if (height === last) return;
|
||||
last = height;
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the page's lowest content ends, and what covers the window's bottom edge. */
|
||||
async function measureMainEnd(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const main = document.getElementById("main-content")!;
|
||||
const wrapper = main.querySelector<HTMLElement>(
|
||||
'[data-slot="main-content-wrapper"]',
|
||||
)!;
|
||||
let contentBottom = 0;
|
||||
for (const el of wrapper.querySelectorAll<HTMLElement>("*")) {
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.position === "fixed" || cs.visibility === "hidden") continue;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) continue;
|
||||
let p = el.parentElement;
|
||||
let nested = false;
|
||||
while (p && p !== main) {
|
||||
const ps = getComputedStyle(p);
|
||||
if (
|
||||
/(auto|scroll|hidden)/.test(ps.overflowY) &&
|
||||
p.scrollHeight > p.clientHeight + 1
|
||||
) {
|
||||
nested = true;
|
||||
break;
|
||||
}
|
||||
p = p.parentElement;
|
||||
}
|
||||
if (nested) continue;
|
||||
contentBottom = Math.max(contentBottom, rect.bottom);
|
||||
}
|
||||
const visible = (el: Element | null): el is HTMLElement =>
|
||||
!!el &&
|
||||
getComputedStyle(el).display !== "none" &&
|
||||
el.getBoundingClientRect().height > 0;
|
||||
const bar = document.querySelector('[data-slot="bottom-nav"]');
|
||||
const mainRect = main.getBoundingClientRect();
|
||||
return {
|
||||
atEnd: main.scrollTop + main.clientHeight >= main.scrollHeight - 1,
|
||||
scrolls: main.scrollHeight > main.clientHeight,
|
||||
contentBottom,
|
||||
mainBottom: mainRect.bottom,
|
||||
barTop: visible(bar) ? bar.getBoundingClientRect().top : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
for (const vp of VIEWPORTS) {
|
||||
test.describe(`${vp.name} (${vp.width}x${vp.height})`, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||
});
|
||||
|
||||
test("every rail destination scrolls into view and takes a tap", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openDashboard(page);
|
||||
await expectShellPinned(page);
|
||||
|
||||
const aside = page.locator("aside");
|
||||
const railShown = vp.width >= 768;
|
||||
if (!railShown) {
|
||||
// Split View and Slide Over get the phone shell: the bar, no rail.
|
||||
await expect(aside).toBeHidden();
|
||||
await expect(page.locator('[data-slot="bottom-nav"]')).toBeVisible();
|
||||
return;
|
||||
}
|
||||
await expect(aside).toBeVisible();
|
||||
|
||||
// Nothing in the rail is clipped by the rail: the logo band, the list
|
||||
// region, the pinned footer and the account row all sit inside it, in
|
||||
// that order, and the list region alone gives up height.
|
||||
const parts = await page.evaluate(() => {
|
||||
const asideEl = document.querySelector("aside")!;
|
||||
const box = asideEl.getBoundingClientRect();
|
||||
const pick = (sel: string) =>
|
||||
asideEl.querySelector(sel)!.getBoundingClientRect();
|
||||
const column = asideEl.querySelector('[data-slot="sidebar-column"]')!;
|
||||
const user = column.lastElementChild!.getBoundingClientRect();
|
||||
return {
|
||||
clipped: asideEl.scrollHeight - asideEl.clientHeight,
|
||||
asideTop: box.top,
|
||||
asideBottom: box.bottom,
|
||||
header: pick('[data-slot="sidebar-header"]'),
|
||||
region: pick('[data-slot="sidebar-nav-region"]'),
|
||||
footer: pick('[data-slot="sidebar-footer"]'),
|
||||
user,
|
||||
overscroll: getComputedStyle(
|
||||
asideEl.querySelector('[data-slot="sidebar-nav-region"] > nav')!,
|
||||
).overscrollBehaviorY,
|
||||
};
|
||||
});
|
||||
expect(parts.clipped).toBeLessThanOrEqual(0);
|
||||
expect(parts.header.top).toBeGreaterThanOrEqual(parts.asideTop - 0.5);
|
||||
expect(parts.region.top).toBeGreaterThanOrEqual(
|
||||
parts.header.bottom - 0.5,
|
||||
);
|
||||
expect(parts.footer.top).toBeGreaterThanOrEqual(
|
||||
parts.region.bottom - 0.5,
|
||||
);
|
||||
expect(parts.user.top).toBeGreaterThanOrEqual(parts.footer.bottom - 0.5);
|
||||
expect(parts.user.bottom).toBeLessThanOrEqual(parts.asideBottom + 0.5);
|
||||
expect(parts.overscroll).toBe("contain");
|
||||
await expect(
|
||||
aside.locator('[data-slot="sidebar-collapse-toggle"]'),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
await expect(
|
||||
aside.locator('[data-slot="nav-settings-link"]'),
|
||||
).toBeInViewport({ ratio: 1 });
|
||||
|
||||
// Every destination: scrolled into the list's view, whole, on top
|
||||
// (not under the cue, the footer or anything else), and actionable.
|
||||
const nav = railNav(page);
|
||||
const links = nav.locator("a[href]");
|
||||
const count = await links.count();
|
||||
expect(count).toBeGreaterThanOrEqual(10);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const link = links.nth(i);
|
||||
await link.scrollIntoViewIfNeeded();
|
||||
await expect
|
||||
.poll(() =>
|
||||
link.evaluate((el) => {
|
||||
const navEl = el.closest("nav")!;
|
||||
const r = el.getBoundingClientRect();
|
||||
const n = navEl.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(
|
||||
r.left + r.width / 2,
|
||||
r.top + r.height / 2,
|
||||
);
|
||||
return (
|
||||
r.top >= n.top - 0.5 &&
|
||||
r.bottom <= n.bottom + 0.5 &&
|
||||
!!hit &&
|
||||
el.contains(hit)
|
||||
);
|
||||
}),
|
||||
)
|
||||
.toBe(true);
|
||||
await link.click({ trial: true });
|
||||
}
|
||||
|
||||
// A list taller than its region shows the touch cue at the edge that
|
||||
// has more, and a tap on it is a way there.
|
||||
const overflows = await nav.evaluate(
|
||||
(el) => el.scrollHeight > el.clientHeight + 1,
|
||||
);
|
||||
const down = page.locator(
|
||||
'[data-slot="sidebar-nav-scroll-cue"][data-direction="down"]',
|
||||
);
|
||||
const up = page.locator(
|
||||
'[data-slot="sidebar-nav-scroll-cue"][data-direction="up"]',
|
||||
);
|
||||
await nav.evaluate((el) => (el.scrollTop = 0));
|
||||
if (overflows) {
|
||||
await expect(down).toBeVisible();
|
||||
await expect(up).toHaveCount(0);
|
||||
await down.tap();
|
||||
await expect
|
||||
.poll(() => nav.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
await expect(up).toBeVisible();
|
||||
await nav.evaluate((el) => (el.scrollTop = el.scrollHeight));
|
||||
await expect(down).toHaveCount(0);
|
||||
} else {
|
||||
await expect(down).toHaveCount(0);
|
||||
await expect(up).toHaveCount(0);
|
||||
}
|
||||
|
||||
// And the last destination is a real tap away.
|
||||
const lastLink = links.nth(count - 1);
|
||||
const href = await lastLink.getAttribute("href");
|
||||
await lastLink.scrollIntoViewIfNeeded();
|
||||
await lastLink.tap();
|
||||
await expect(page).toHaveURL(new RegExp(`${href}$`));
|
||||
});
|
||||
|
||||
// The Coach button's clearance is held by `shell-viewport.test.ts`: the
|
||||
// button renders only with an AI provider, which the suite's accounts
|
||||
// do not have, so a check here would never run.
|
||||
test("main scrolls to its last line, clear of the bottom bar", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openDashboard(page);
|
||||
await scrollMainToEnd(page);
|
||||
const end = await measureMainEnd(page);
|
||||
expect(end.scrolls).toBe(true);
|
||||
expect(end.atEnd).toBe(true);
|
||||
expect(end.contentBottom).toBeLessThanOrEqual(end.mainBottom + 0.5);
|
||||
if (end.barTop !== null) {
|
||||
expect(end.contentBottom).toBeLessThanOrEqual(end.barTop);
|
||||
}
|
||||
await expectShellPinned(page);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** After a modal closes, the page behind takes pointer and wheel again and scrolls. */
|
||||
async function expectPageScrollsAgain(page: Page, browserName: string) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => ({
|
||||
locked: document.body.hasAttribute("data-scroll-locked"),
|
||||
pointer: getComputedStyle(document.body).pointerEvents,
|
||||
})),
|
||||
)
|
||||
.toEqual({ locked: false, pointer: "auto" });
|
||||
const before = await page.evaluate(() => {
|
||||
const main = document.getElementById("main-content")!;
|
||||
main.scrollTop = 0;
|
||||
const r = main.getBoundingClientRect();
|
||||
const x = r.left + r.width / 2;
|
||||
const y = r.top + r.height / 2;
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
const wheel = new WheelEvent("wheel", {
|
||||
deltaY: 120,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
});
|
||||
hit?.dispatchEvent(wheel);
|
||||
return {
|
||||
onMain: !!hit && main.contains(hit),
|
||||
blocked: wheel.defaultPrevented,
|
||||
x,
|
||||
y,
|
||||
};
|
||||
});
|
||||
// Nothing invisible lies over `<main>`, and no scroll lock swallows the
|
||||
// wheel (react-remove-scroll cancels it outside its own layer).
|
||||
expect(before.onMain).toBe(true);
|
||||
expect(before.blocked).toBe(false);
|
||||
// A real wheel where the engine has one. Mobile WebKit under Playwright
|
||||
// has none (and no native touch scroll), so there the two checks above
|
||||
// are the proof: nothing covers `<main>` and nothing cancels its events.
|
||||
if (browserName === "webkit") return;
|
||||
await page.mouse.move(before.x, before.y);
|
||||
await page.mouse.wheel(0, 600);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => document.getElementById("main-content")!.scrollTop),
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const MODAL_VIEWPORTS = [
|
||||
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
|
||||
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
|
||||
{ name: "Split View two thirds", width: 694, height: 1024 },
|
||||
] as const;
|
||||
|
||||
for (const vp of MODAL_VIEWPORTS) {
|
||||
test.describe(`${vp.name}: scroll after a modal`, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||
});
|
||||
|
||||
test("the page scrolls after the command palette dialog closes", async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await openDashboard(page);
|
||||
await page.locator('[data-slot="command-palette-trigger"]:visible').tap();
|
||||
const input = page.locator('[data-slot="command-palette-input"]');
|
||||
await expect(input).toBeVisible();
|
||||
// The scroll lock makes the body `position: relative`; the shell
|
||||
// behind the dialog keeps the window's box all the same.
|
||||
await expectShellPinned(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(input).toBeHidden();
|
||||
await expectPageScrollsAgain(page, browserName);
|
||||
});
|
||||
|
||||
test("the page scrolls after a sheet closes", async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
await openDashboard(page);
|
||||
if (vp.width < 768) {
|
||||
// The phone shell's More sheet: the draggable bottom sheet.
|
||||
await page.locator('[data-testid="bottom-nav-more"]').tap();
|
||||
const sheet = page.locator('[data-testid="bottom-nav-more-sheet"]');
|
||||
await expect(sheet).toBeVisible();
|
||||
await expectShellPinned(page);
|
||||
await sheet.locator('[data-slot="sheet-close-button"]').tap();
|
||||
await expect(sheet).toBeHidden();
|
||||
} else {
|
||||
// The day, a sheet from the right below 1280 px.
|
||||
await mockDay(page);
|
||||
const day = new Date(Date.now() - 86_400_000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
await page.goto(`/?day=${day}`, { waitUntil: "domcontentloaded" });
|
||||
const panel = page.locator('[data-slot="day-panel"]');
|
||||
await expect(panel).toBeVisible();
|
||||
await expectShellPinned(page);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).toBeHidden();
|
||||
}
|
||||
await expectPageScrollsAgain(page, browserName);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("a vertical swipe that starts on a tab strip is the page's", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1180, height: 820 });
|
||||
await page.goto("/checkups", { waitUntil: "domcontentloaded" });
|
||||
const strip = page.locator('#main-content [data-slot="tabs-list"]').first();
|
||||
await expect(strip).toBeVisible();
|
||||
// `pan-x` here refused the vertical pan, so the page did not move.
|
||||
const touchAction = await strip.evaluate(
|
||||
(el) => getComputedStyle(el).touchAction,
|
||||
);
|
||||
expect(touchAction).toBe("auto");
|
||||
});
|
||||
@@ -123,6 +123,8 @@ export default defineConfig({
|
||||
"pwa-offline.spec.ts",
|
||||
// Runs only in the WebKit phone project.
|
||||
"bottom-sheet.spec.ts",
|
||||
// Runs only in the two tablet projects.
|
||||
"tablet-shell.spec.ts",
|
||||
],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
@@ -240,6 +242,8 @@ export default defineConfig({
|
||||
"pwa-offline.spec.ts",
|
||||
// Runs only in the WebKit phone project.
|
||||
"bottom-sheet.spec.ts",
|
||||
// Runs only in the two tablet projects.
|
||||
"tablet-shell.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
|
||||
@@ -333,7 +337,28 @@ export default defineConfig({
|
||||
colorScheme: "dark" as const,
|
||||
},
|
||||
},
|
||||
// The app shell on an iPad (`tablet-shell.spec.ts`): the rail
|
||||
// under a finger, `<main>` to its last line, scroll after a modal.
|
||||
// In CI's WebKit job beside `webkit-iphone`. The spec sets each
|
||||
// iPad size itself; the profile supplies touch and `isMobile`.
|
||||
{
|
||||
name: "webkit-tablet",
|
||||
testMatch: ["tablet-shell.spec.ts"],
|
||||
use: {
|
||||
...devices["iPad Pro 11 landscape"],
|
||||
colorScheme: "dark" as const,
|
||||
},
|
||||
},
|
||||
]),
|
||||
// The same spec on an Android tablet, in the Chromium shards.
|
||||
{
|
||||
name: "chromium-tablet",
|
||||
testMatch: ["tablet-shell.spec.ts"],
|
||||
use: {
|
||||
...devices["Galaxy Tab S4 landscape"],
|
||||
colorScheme: "dark" as const,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// Spin up the production build for E2E. `pnpm build` is run
|
||||
|
||||
Reference in new issue
Block a user