Files
HealthLog/e2e/settings-admin-one-piece.spec.ts

526 lines
21 KiB
TypeScript

import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { settleBeforeMeasure } from "./utils/settle";
/**
* Settings + Admin read as one surface — measured, not eyeballed.
*
* Three claims, each with a number behind it:
*
* 1. **No jump on a route switch.** Every settings and admin route puts its
* heading at the SAME distance from the top of the scroll container, on
* both viewports. This is the U3 complaint stated as a measurement: the
* shells hand-rolled the heading, one page used `text-xs sm:text-sm` for
* the explainer and another `text-sm`, so the first card started at a
* different y depending on where you came from.
* 2. **One card rhythm.** Inside a settings/admin card, the header's bottom
* and the first body block's top sit exactly one gap apart — the card's
* own gap, `gap-4` under `md` and `gap-6` above it. Three call-site
* techniques used to produce three values here.
* 3. **One reading edge.** Every card body starts at the card's inner edge.
* The `pl-7` gutter put some bodies 28 px in and some at the edge, on the
* same page, one card apart.
* 4. **One row rhythm in a list card.** A card whose rows are a `divide-y`
* ledger has nothing between two rows but the divider line, and its first
* row starts at the card's inner top edge. The card contract carries a
* `gap-4 md:gap-6` for a stack of blocks; dropping the padding without
* dropping the gap leaves that gap standing between the rows as dead,
* unclickable space the top of the list does not have.
*
* Runs in EN and DE, at desktop and phone widths. German runs 15-25% longer,
* and an explainer that wraps in German where English fits is the same defect
* the one-sentence rule exists to prevent — so at desktop width a wrap is a
* failure in either language. On a phone it is reported, not asserted: a
* 70-character sentence needs about 380 px of column at `text-sm`, so a
* 393 px viewport wraps most of them, and the alternative is the truncation
* this work removed.
*/
test.use({ storageState: STORAGE_STATE_PATH });
const SETTINGS_ROUTES = [
"/settings",
"/settings/account",
"/settings/security",
"/settings/notifications",
"/settings/integrations",
"/settings/ai",
"/settings/coach",
"/settings/modules",
"/settings/layout",
"/settings/thresholds",
"/settings/sources",
"/settings/environment",
"/settings/export",
"/settings/gesundheitsakte",
"/settings/privacy",
"/settings/api",
"/settings/mcp",
"/settings/advanced",
"/settings/about",
] as const;
/**
* The Arrangement hub's children, plus (v1.44) the module pages under
* Modules, which use the same back-link header. They are the routes whose header block
* changed MOST — the shell used to hand-roll a `space-y-6` between the
* back-link and the title, and `PageHeader`'s documented contract is
* `space-y-1.5`. Nothing measured them before, so the step went from 24 px
* to 6 px unobserved. 6 px is the standards' own number (§1's PageHeader
* row), so the measurement pins it rather than reverting it.
*/
const LAYOUT_CHILD_ROUTES = [
"/settings/layout/dashboard",
"/settings/layout/insights",
"/settings/layout/medications",
"/settings/layout/labs",
"/settings/layout/illness",
"/settings/layout/vorsorge",
"/settings/modules/medications",
"/settings/modules/mood",
"/settings/modules/labs",
] as const;
/** The `space-y-1.5` between a back-link and the title it sits above. */
const BACK_LINK_STEP = 6;
const ADMIN_ROUTES = [
"/admin",
"/admin/system-status",
"/admin/general",
"/admin/services",
"/admin/users",
"/admin/invites",
"/admin/integrations",
"/admin/coach",
"/admin/reminders",
"/admin/backups",
"/admin/encryption",
"/admin/app-logs",
"/admin/api-tokens",
"/admin/login-overview",
"/admin/danger-zone",
] as const;
const ROUTES = [...SETTINGS_ROUTES, ...LAYOUT_CHILD_ROUTES, ...ADMIN_ROUTES];
/**
* The card's own gap. `SettingsCard` inherits the `<Card>` contract —
* `gap-4` under `md`, `gap-6` from `md` up — so the expected value is a
* function of the viewport, not a constant. Asserting a constant here would
* have hidden exactly the defect this run found: the `as="section"` branch
* was writing a flat `gap-4` and sat one step tighter than its neighbour on
* every desktop width.
*/
const cardGapFor = (width: number) => (width >= 768 ? 24 : 16);
/** Sub-pixel and font-metric slack. */
const TOLERANCE = 1.5;
type HeaderGeometry = {
route: string;
/** `<h1>` top, relative to the scroll container's content top. */
headingTop: number;
/** Height of the whole title + explainer block. */
headerHeight: number;
/** Rendered line count of the explainer (1 = it stayed on one line). */
explainerLines: number;
explainerText: string;
/**
* Distance from a hub back-link's bottom to the heading's top, or `null`
* on a route that has no back-link. A route WITH one has a taller header
* by construction, so the two families are compared separately.
*/
backLinkStep: number | null;
};
async function measureHeader(
page: import("@playwright/test").Page,
route: string,
): Promise<HeaderGeometry | null> {
return page.evaluate(
({ route }) => {
const main = document.querySelector("main");
// On a desktop width the visible heading is the real `<h1>`; on a phone
// the shells paint it above the chip strip as
// `role="heading" aria-level="1"` and the `<h1>` in the grid row is
// display:none. Measure whichever one has a box — measuring the hidden
// copy reports a constant zero and calls it stability.
const heading = Array.from(
document.querySelectorAll<HTMLElement>(
'main h1, main [role="heading"][aria-level="1"]',
),
).find((el) => el.getBoundingClientRect().height > 0);
if (!main || !heading) return null;
const block = heading.parentElement!;
const explainer = block.querySelector("p");
const mainTop = main.getBoundingClientRect().top;
const headBox = heading.getBoundingClientRect();
const explainerBox = explainer?.getBoundingClientRect();
// The hub back-link rides above the title inside the same header — and
// like the heading it exists twice, because the shell paints the whole
// block for both breakpoints. `querySelector` returns the first in
// DOCUMENT order, which on a desktop width is the hidden mobile copy;
// take the one with a box, the same way the heading is chosen.
const backLink = Array.from(
document.querySelectorAll<HTMLElement>(
'main [data-slot="settings-hub-back-link"]',
),
).find((el) => el.getBoundingClientRect().height > 0);
const backLinkBox = backLink?.getBoundingClientRect();
const style = explainer ? getComputedStyle(explainer) : null;
const lineHeight = style ? parseFloat(style.lineHeight) : 0;
return {
route,
headingTop: headBox.top - mainTop,
// Heading top to explainer bottom. The parent block's own box is not
// a reliable proxy on a phone, where the heading is rendered outside
// the grid row it belongs to on desktop.
headerHeight: (explainerBox?.bottom ?? headBox.bottom) - headBox.top,
explainerLines:
lineHeight > 0 && explainerBox
? Math.round(explainerBox.height / lineHeight)
: 0,
explainerText: explainer?.textContent?.trim() ?? "",
backLinkStep:
backLinkBox && backLinkBox.height > 0
? headBox.top - backLinkBox.bottom
: null,
};
},
{ route },
);
}
/**
* Header-to-body gap and body left edge, per card on the page.
* Returns one entry per card that has both a header and a following sibling.
*/
async function measureCards(page: import("@playwright/test").Page) {
return page.evaluate(() => {
const out: { gap: number; bodyInset: number }[] = [];
for (const card of document.querySelectorAll('[data-slot="card"]')) {
const header = card.querySelector(":scope > header");
if (!header) continue;
const next = header.nextElementSibling as HTMLElement | null;
if (!next) continue;
if (next.getBoundingClientRect().height === 0) continue;
const cardBox = card.getBoundingClientRect();
const cardStyle = getComputedStyle(card);
const cardInnerLeft = cardBox.left + parseFloat(cardStyle.paddingLeft);
out.push({
gap:
next.getBoundingClientRect().top -
header.getBoundingClientRect().bottom,
bodyInset: next.getBoundingClientRect().left - cardInnerLeft,
});
}
return out;
});
}
/**
* Row rhythm inside a `divide-y` list card, per card on the page.
*
* A list card is recognised by its own shape rather than by a class name:
* three or more direct children, no card header among them, and a divider on
* every seam between them. That is the ledger pattern wherever it is written,
* and the three-row floor keeps a header-plus-one-bordered-block card — which
* claim 2 already owns — out of this one.
*
* `topInset` is the space above the FIRST row, measured from the card's inner
* top edge; `rowGaps` is the space between consecutive rows, over and above
* the divider line itself. Both are zero in a correct ledger — which is the
* whole point: the first row and every later row sit the same distance from
* the line above them.
*/
async function measureListCards(page: import("@playwright/test").Page) {
return page.evaluate(() => {
const out: { slot: string; topInset: number; rowGaps: number[] }[] = [];
for (const card of document.querySelectorAll('[data-slot="card"]')) {
if (card.querySelector(":scope > header")) continue;
const rows = Array.from(card.children) as HTMLElement[];
if (rows.length < 3) continue;
// Tailwind writes the divider on the top edge of every row but the
// first, or on the bottom edge of every row but the last, depending on
// the utility. Accept either — the pattern is the same ledger.
const dividedFromTop = rows
.slice(1)
.every((row) => parseFloat(getComputedStyle(row).borderTopWidth) > 0);
const dividedFromBottom = rows
.slice(0, -1)
.every(
(row) => parseFloat(getComputedStyle(row).borderBottomWidth) > 0,
);
if (!dividedFromTop && !dividedFromBottom) continue;
const cardStyle = getComputedStyle(card);
const cardInnerTop =
card.getBoundingClientRect().top +
parseFloat(cardStyle.borderTopWidth) +
parseFloat(cardStyle.paddingTop);
out.push({
slot: card.getAttribute("data-testid") ?? "card",
topInset: rows[0].getBoundingClientRect().top - cardInnerTop,
// The divider is drawn inside the row's border box, so it is already
// inside the measured top edge — a correct ledger reports 0 here.
rowGaps: rows
.slice(1)
.map(
(row, index) =>
row.getBoundingClientRect().top -
rows[index].getBoundingClientRect().bottom,
),
});
}
return out;
});
}
for (const locale of ["en", "de"] as const) {
test.describe(`one piece — ${locale}`, () => {
test(`page headers land at one height across every route (${locale})`, async ({
page,
}) => {
test.setTimeout(420_000);
await page.context().addCookies([
{
name: "healthlog-locale",
value: locale,
url: process.env.E2E_BASE_URL ?? "http://localhost:3000",
},
]);
const measured: HeaderGeometry[] = [];
const skipped: string[] = [];
for (const route of ROUTES) {
const response = await page.goto(route, {
waitUntil: "domcontentloaded",
});
if (response && response.status() >= 400) continue;
// A hub route may client-redirect to its first section; wait for the
// heading and skip a route that legitimately has none rather than
// failing the sweep on it.
const heading = page
.locator(
'main h1:visible, main [role="heading"][aria-level="1"]:visible',
)
.first();
if (!(await heading.isVisible().catch(() => false))) {
await heading
.waitFor({ state: "visible", timeout: 8_000 })
.catch(() => {});
}
if (!(await heading.isVisible().catch(() => false))) {
skipped.push(route);
continue;
}
await settleBeforeMeasure(page, heading);
const geometry = await measureHeader(page, route);
if (geometry) measured.push(geometry);
}
console.log(
`[${locale}] routes without an <h1>: ${skipped.join(", ") || "none"}`,
);
// The sweep has to actually look at something.
expect(measured.length).toBeGreaterThan(32);
// Two families, compared separately: a route with a hub back-link
// carries an extra row above the title, so it is TALLER by
// construction and comparing it against a plain route would report
// that structure as drift.
const plain = measured.filter((m) => m.backLinkStep === null);
const withBackLink = measured.filter((m) => m.backLinkStep !== null);
expect(
withBackLink.length,
"the Layout children were meant to be measured",
).toBe(LAYOUT_CHILD_ROUTES.length);
const tops = plain.map((m) => m.headingTop);
const heights = plain.map((m) => m.headerHeight);
const spreadTop = Math.max(...tops) - Math.min(...tops);
const spreadHeight = Math.max(...heights) - Math.min(...heights);
const childTops = withBackLink.map((m) => m.headingTop);
const childSpreadTop = Math.max(...childTops) - Math.min(...childTops);
const steps = withBackLink.map((m) => m.backLinkStep!);
console.log(
`[${locale}] back-link routes: heading top ${Math.min(...childTops).toFixed(1)}…${Math.max(...childTops).toFixed(1)} ` +
`(spread ${childSpreadTop.toFixed(1)}px); back-link→title step ${[
...new Set(steps.map((v) => v.toFixed(1))),
].join(", ")}px`,
);
// Attached so a failure reports WHICH route moved, not just that one did.
console.log(
`[${locale}] heading top: ${Math.min(...tops).toFixed(1)}…${Math.max(...tops).toFixed(1)} (spread ${spreadTop.toFixed(1)}px)\n` +
`[${locale}] header height: ${Math.min(...heights).toFixed(1)}…${Math.max(...heights).toFixed(1)} (spread ${spreadHeight.toFixed(1)}px)\n` +
measured
.map(
(m) =>
` ${m.route.padEnd(32)} top ${m.headingTop.toFixed(1)} h ${m.headerHeight.toFixed(1)} lines ${m.explainerLines}`,
)
.join("\n"),
);
// A page explainer that wraps is a text problem, and it is the one the
// ≤120-character rule exists to prevent. Reported per locale.
const wrapped = measured.filter((m) => m.explainerLines > 1);
console.log(
`[${locale}] explainers wrapping past one line: ${wrapped.length}` +
wrapped.map((m) => `\n ${m.route}: ${m.explainerText}`).join(""),
);
// The no-jump claim, on every viewport: the title starts at the same y
// whichever route you came from.
expect(
spreadTop,
"heading top drifts between routes",
).toBeLessThanOrEqual(TOLERANCE);
expect(
childSpreadTop,
"heading top drifts between the Layout children",
).toBeLessThanOrEqual(TOLERANCE);
// The back-link step is `PageHeader`'s documented `space-y-1.5`. The
// shell used to hand-roll `space-y-6` here; 6 px is the standards'
// number, and this is what stops it drifting back to 24.
for (const measurement of withBackLink) {
expect(
Math.abs(measurement.backLinkStep! - BACK_LINK_STEP),
`${measurement.route}: back-link→title step is ${measurement.backLinkStep!.toFixed(1)}px, not ${BACK_LINK_STEP}px`,
).toBeLessThanOrEqual(TOLERANCE);
}
// The header's HEIGHT can only be constant where every explainer fits on
// one line, and on a 393 px phone most one-sentence explainers do not —
// a 70-character sentence at `text-sm` needs about 380 px of column. The
// honest rule there is that the block grows by whole lines of text, not
// that it never grows; forcing one height on a phone means truncating,
// which is the defect this work removed. So the height is pinned at the
// width where one line is achievable, and on a phone the wrap count is
// reported rather than asserted.
const isPhone = (page.viewportSize()?.width ?? 1280) < 768;
if (!isPhone) {
expect(
spreadHeight,
"header block height drifts between routes",
).toBeLessThanOrEqual(TOLERANCE);
expect(
wrapped.map((m) => `${m.route}: ${m.explainerText}`),
"a page explainer wraps to a second line at desktop width",
).toEqual([]);
} else {
// Every header is one or two lines of the same type scale — never a
// third, which would mean a subtitle nobody trimmed.
expect(
measured.filter((m) => m.explainerLines > 2).map((m) => m.route),
"a page explainer runs to three lines on a phone",
).toEqual([]);
}
});
test(`cards share one header gap and one reading edge (${locale})`, async ({
page,
}) => {
test.setTimeout(420_000);
await page.context().addCookies([
{
name: "healthlog-locale",
value: locale,
url: process.env.E2E_BASE_URL ?? "http://localhost:3000",
},
]);
const gaps: number[] = [];
const insets: number[] = [];
const expectedGap = cardGapFor(page.viewportSize()?.width ?? 1280);
for (const route of ROUTES) {
const response = await page.goto(route, {
waitUntil: "domcontentloaded",
});
if (response && response.status() >= 400) continue;
const heading = page
.locator(
'main h1:visible, main [role="heading"][aria-level="1"]:visible',
)
.first();
if (!(await heading.isVisible().catch(() => false))) {
await heading
.waitFor({ state: "visible", timeout: 8_000 })
.catch(() => {});
}
await settleBeforeMeasure(page, page.locator("main").first());
for (const card of await measureCards(page)) {
gaps.push(card.gap);
insets.push(card.bodyInset);
}
}
expect(gaps.length).toBeGreaterThan(30);
const offGap = gaps.filter((g) => Math.abs(g - expectedGap) > TOLERANCE);
const offEdge = insets.filter((i) => Math.abs(i) > TOLERANCE);
console.log(
`[${locale}] cards measured: ${gaps.length}; header→body ≠ ${expectedGap}px: ${offGap.length} (${[...new Set(offGap.map((g) => g.toFixed(1)))].join(", ")})\n` +
`[${locale}] bodies off the card edge: ${offEdge.length} (${[...new Set(offEdge.map((i) => i.toFixed(1)))].join(", ")})`,
);
expect(offGap, "header→body gap is not the card's own gap").toEqual([]);
expect(offEdge, "card body does not start at the card edge").toEqual([]);
});
test(`list cards keep one row rhythm (${locale})`, async ({ page }) => {
test.setTimeout(420_000);
await page.context().addCookies([
{
name: "healthlog-locale",
value: locale,
url: process.env.E2E_BASE_URL ?? "http://localhost:3000",
},
]);
const offenders: string[] = [];
let measured = 0;
for (const route of ROUTES) {
const response = await page.goto(route, {
waitUntil: "domcontentloaded",
});
if (response && response.status() >= 400) continue;
const heading = page
.locator(
'main h1:visible, main [role="heading"][aria-level="1"]:visible',
)
.first();
if (!(await heading.isVisible().catch(() => false))) {
await heading
.waitFor({ state: "visible", timeout: 8_000 })
.catch(() => {});
}
await settleBeforeMeasure(page, page.locator("main").first());
for (const card of await measureListCards(page)) {
measured += 1;
const worstGap = Math.max(0, ...card.rowGaps);
if (
Math.abs(card.topInset) > TOLERANCE ||
worstGap > TOLERANCE ||
Math.abs(worstGap - Math.min(...card.rowGaps)) > TOLERANCE
) {
offenders.push(
`${route} (${card.slot}): first row inset ${card.topInset.toFixed(1)}px, ` +
`between rows ${[...new Set(card.rowGaps.map((g) => g.toFixed(1)))].join("/")}px`,
);
}
}
}
console.log(`[${locale}] list cards measured: ${measured}`);
// The sweep has to actually find the ledgers it is about.
expect(measured).toBeGreaterThan(0);
expect(
offenders,
"a divided list card puts dead space above its rows that the first row does not have",
).toEqual([]);
});
});
}