Files
HealthLog/e2e/settings-overscroll.spec.ts
T
Marc-André Bombeck 620b26a1e9 test(e2e): guard the settings scroll area against nested height reserves
The over-scroll class keeps recurring (last instance fixed in the
settings shell): a page-level viewport-height reserve or bottom gutter
inside the scrolling main stacks on the shell-owned padding and the
page scrolls past the last card into an empty band. Pin the invariant
instead of the instance: across the settings routes (short hub, long
sortable-list subpages, long form section) at desktop and phone
viewports, main.scrollHeight must stay within the last content
element's bottom edge plus the shell-owned padding and a small
tolerance. Descendants of nested max-h scroll containers are excluded
from the content measure — their overflow never contributes page
scroll height.
2026-07-05 18:31:24 +02:00

115 lines
4.7 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Vertical over-scroll regression guard (the recurring class behind the
* v1.25.11 settings fix): the scroll viewport must never extend far past
* the last content element. The rule the guard pins is structural — only
* the AuthShell owns the scroll height and the bottom padding
* (`pt-6 pb-20` on the wrapper, plus the `<md` bottom-nav clearance on
* `<main>`); a page/section must never add its own viewport-height
* reserve or bottom gutter, because every such nested reserve stacks on
* the shell's own budget and reopens the dark-band-below-the-last-card
* bug.
*
* The assertion: `main.scrollHeight` ≤ bottom edge of the lowest content
* element + the shell-owned padding + tolerance. Runs across the
* settings routes (short hub, long sortable-list subpages, long form
* section) at desktop AND phone-shaped viewports, because the two shell
* paddings differ per breakpoint.
*/
const ROUTES = [
"/settings/layout",
"/settings/layout/dashboard",
"/settings/layout/insights",
"/settings/account",
"/settings/notifications",
] as const;
const VIEWPORTS = [
{ name: "desktop", width: 1440, height: 900, expectedPad: 80 }, // wrapper pb-20
{ name: "phone", width: 390, height: 844, expectedPad: 144 }, // pb-20 + main pb-16
] as const;
test.describe("settings vertical over-scroll guard", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(({}, testInfo) => {
test.skip(
testInfo.project.name !== "chromium-desktop",
"viewport-driven spec; desktop project only",
);
});
for (const vp of VIEWPORTS) {
for (const route of ROUTES) {
test(`no over-scroll on ${route} (${vp.name})`, async ({ page }) => {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.goto(route, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
// Let entry transitions / late queries settle before measuring.
await page.waitForTimeout(500);
const dims = await page.evaluate(() => {
const main = document.getElementById("main-content");
if (!main) return null;
const wrapper = main.firstElementChild as HTMLElement | null;
if (!wrapper) return null;
// Lowest content edge: max bottom over the wrapper's visible,
// non-fixed descendants, in the scroll container's coordinate
// space. Elements inside nested scroll containers are clipped
// by their own overflow and never add page scroll height, so
// skip anything whose scrollable ancestor is not `main`.
let maxBottom = 0;
const mainTop = main.getBoundingClientRect().top;
for (const el of wrapper.querySelectorAll<HTMLElement>("*")) {
const cs = getComputedStyle(el);
if (cs.position === "fixed") continue;
const rect = el.getBoundingClientRect();
if (rect.height === 0 && rect.width === 0) continue;
// Skip descendants of inner scroll containers (their
// overflow does not contribute to the page scroll height).
let p = el.parentElement;
let inner = false;
while (p && p !== main) {
const pcs = getComputedStyle(p);
if (
(pcs.overflowY === "auto" || pcs.overflowY === "scroll") &&
p.scrollHeight > p.clientHeight
) {
inner = true;
break;
}
p = p.parentElement;
}
if (inner) continue;
const bottom = rect.bottom + main.scrollTop - mainTop;
if (bottom > maxBottom) maxBottom = bottom;
}
return {
scrollHeight: main.scrollHeight,
clientHeight: main.clientHeight,
contentBottom: Math.round(maxBottom),
};
});
expect(dims, "main-content / wrapper present").not.toBeNull();
if (!dims) return;
// A page shorter than the viewport cannot over-scroll at all.
if (dims.scrollHeight <= dims.clientHeight) return;
const tolerance = 24;
expect(
dims.scrollHeight,
`scrollHeight=${dims.scrollHeight} contentBottom=${dims.contentBottom} ` +
`expectedPad=${vp.expectedPad} — the scroll area extends past the ` +
`last content element + the shell-owned padding; some nested ` +
`min-h / bottom padding is stacking on the shell budget`,
).toBeLessThanOrEqual(dims.contentBottom + vp.expectedPad + tolerance);
});
}
}
});