mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
A spec that arms `page.route(...)` in a `beforeEach` and never drops the handler races its own teardown. The context closes, an async handler is still awaiting, its next call rejects with "Test ended.", and Playwright attributes that to the test that had just passed. With `retries: 2` plus `failOnFlakyTests`, which spec bites is a matter of timing. `e2e/setup/test.ts` re-exports Playwright's `test` extended with an auto fixture that unroutes every page and the context after each test, and clears the page event listeners a spec subscribes to; `expect` rides along so a spec needs one import. Auto means a spec author cannot forget it and does not have to know it exists. Every spec now takes `test` from there, and `e2e-route-cleanup-guard.test.ts` keeps the import boundary closed with an allowlist that carries its reason. The per-file `afterEach` on the cycle spec goes away — leaving both would read as not trusting the fixture. Not covered: a page inside a context a spec builds itself. Only `share-documents.spec.ts` does that, and it owns those contexts.
161 lines
7.3 KiB
TypeScript
161 lines
7.3 KiB
TypeScript
import { expect, test } from "./setup/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 real
|
|
* content element + the shell-owned padding + tolerance. "Real content"
|
|
* is measured against each element's CONTENT-box bottom, not its padding-
|
|
* box: a sub-shell column that re-declares its own bottom gutter (`pb-*`)
|
|
* no longer folds that gutter into `contentBottom`, so a redundant reserve
|
|
* FAILS the assertion instead of being absorbed by it. Runs across both
|
|
* two-column sub-shells — Settings AND Admin (they share the grid-floor
|
|
* source) — at desktop AND phone-shaped viewports, because the two shell
|
|
* paddings differ per breakpoint.
|
|
*/
|
|
const ROUTES = [
|
|
// Settings sub-shell — short hub, long sortable-list subpages, long form.
|
|
"/settings/layout",
|
|
"/settings/layout/dashboard",
|
|
// The other sortable-list subpages + the Modules hub: each hosts an
|
|
// order editor with the sr-only drag-hint paragraph (see the
|
|
// one-scroll-floor assertion below).
|
|
"/settings/layout/insights",
|
|
"/settings/layout/medications",
|
|
"/settings/layout/mood",
|
|
"/settings/modules",
|
|
"/settings/account",
|
|
"/settings/notifications",
|
|
// Admin sub-shell — short overview + a longer list page. Guards that the
|
|
// pre-#154 column reserve stays retired on every admin breakpoint.
|
|
"/admin",
|
|
"/admin/login-overview",
|
|
"/admin/system-status",
|
|
] 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 + admin 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 CONTENT-box bottom over the wrapper's
|
|
// visible, non-fixed descendants, in the scroll container's
|
|
// coordinate space. Using the content box (padding-box bottom
|
|
// minus the element's own bottom padding + border) means a
|
|
// layout column's own `pb-*` gutter is NOT counted — its last
|
|
// real child (a card) is still measured through the column's
|
|
// content box, so a redundant sub-shell bottom gutter shows up
|
|
// as pure over-scroll instead of inflating the allowed budget.
|
|
// 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 padBottom = parseFloat(cs.paddingBottom) || 0;
|
|
const borderBottom = parseFloat(cs.borderBottomWidth) || 0;
|
|
const bottom =
|
|
rect.bottom - padBottom - borderBottom + 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;
|
|
|
|
// One-scroll-floor (UI-STANDARDS §9): `<main>` is the ONLY vertical
|
|
// scroll surface — the document itself must never become scrollable
|
|
// next to it (a second painted scrollbar + a dead dark band under
|
|
// the shell). The historic offender class: an absolutely-positioned
|
|
// sr-only element (the dnd drag-hint paragraph) whose containing
|
|
// block resolves to the initial containing block because no ancestor
|
|
// between it and the root is positioned — its static position below
|
|
// a long list then extends the DOCUMENT's scrollable overflow while
|
|
// `<main>` clips everything in normal flow.
|
|
const doc = await page.evaluate(() => ({
|
|
scrollHeight: document.documentElement.scrollHeight,
|
|
clientHeight: document.documentElement.clientHeight,
|
|
}));
|
|
expect(
|
|
doc.scrollHeight,
|
|
`document scrollHeight=${doc.scrollHeight} > viewport ` +
|
|
`${doc.clientHeight} — a second vertical scroll surface exists ` +
|
|
`beside <main> (likely an absolutely-positioned element escaping ` +
|
|
`to the initial containing block)`,
|
|
).toBeLessThanOrEqual(doc.clientHeight + 1);
|
|
|
|
// 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);
|
|
});
|
|
}
|
|
}
|
|
});
|