Files
HealthLog/e2e/mobile-route-overflow.spec.ts
T
Marc-André Bombeck c86c7d1a79 refactor(lint): enforce the lib → components and client → server boundaries
Add the in-repo rule `healthlog/import-boundaries`:

- a file under src/lib may not import src/components or src/app (alias,
  relative, re-export and dynamic forms, type-only included);
- a "use client" file may not make a value import of the database client,
  the crypto module, the Prisma client entry, the job queue, pg, pg-boss or
  next/headers. Type-only imports and the Prisma enums entry stay legal.

The four lib files that still import upward are listed in the rule's allow
map with a reason each; a listed file that stops importing upward is
reported, so the map can only shrink.

Five of the nine upward imports are gone by moving pure modules to their
layer, without behaviour change:

- supply-runway estimate → src/lib/medications/supply-runway.ts
  (read by the low-stock job and the medication list read);
- weekday BYDAY tokens → src/lib/medications/scheduling/weekday-tokens.ts
  (read by the medication extraction prompt);
- settings section slugs → src/lib/settings/section-slugs.ts
  (read by the record-settings classification);
- the command palette index → src/components/command-palette/ (it is a
  client index built from component registries).

Proof the rule can fail: an import of @/components/ui/card appended to
src/lib/medications/supply-runway.ts reported libUpward, and a planted
"use client" file importing @/lib/db reported clientServerOnly; both were
removed again.
2026-10-22 04:15:11 +02:00

319 lines
12 KiB
TypeScript

import { readdirSync, statSync } from "node:fs";
import { join, relative, sep } from "node:path";
import type { APIRequestContext } from "@playwright/test";
import { ADMIN_SECTION_SLUGS } from "@/components/admin/section-slugs";
import { MODULE_SETTINGS_PAGE_IDS } from "@/components/settings/module-settings-page-ids";
import { SETTINGS_SECTION_SLUGS } from "@/lib/settings/section-slugs";
import { expect, test } from "./setup/test";
import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup";
import { MOBILE_ROUTES_ANALYTE } from "./setup/mobile-routes-fixture";
import {
expectNoHorizontalOverflow,
settleForOverflowMeasurement,
} from "./utils/horizontal-overflow";
/**
* Every signed-in route, at an iPhone width, does not scroll sideways.
*
* `mobile-horizontal-overflow.spec.ts` sweeps five widths over four
* content-heavy routes. This file is the breadth half: one width, every route.
* It exists because the lab list panned sideways on a phone for weeks while
* the guard was green — `/labs` was simply not on the list. A hand-kept list
* goes stale the day a route is added, so the routes here are read from
* `src/app` itself, and a route this file cannot open fails the run instead of
* dropping out silently:
*
* - a static route is visited as is;
* - a dynamic route needs an entry in `DYNAMIC_ROUTES` that turns its
* segments into concrete paths (a settings slug, a seeded record's id);
* - a route that cannot be swept signed in (auth, invitation, public
* share-link pages) is listed in `NOT_SIGNED_IN` with its reason.
*
* The account is `E2E_MOBILE_ROUTES`, which `globalSetup` fills with one or
* two of everything (`mobile-routes-fixture.ts`): an empty page fits any
* viewport and would prove nothing. It renders in German, the locale with the
* longest common strings, and at 390 px — the iPhone width the lab list broke
* at.
*/
const APP_DIR = join(process.cwd(), "src", "app");
const WIDTH = 390;
/** Route patterns that are not signed-in surfaces, and why. */
const NOT_SIGNED_IN: Record<string, string> = {
"/auth/login": "the sign-in page; a session redirects away from it",
"/auth/register": "registration; a session redirects away from it",
"/invite/[token]":
"an invitation landing page for somebody without an account",
"/c/[token]": "the clinician share-link page, opened without a session",
"/claim/[token]":
"the managed-profile handover link, opened by the person without a session (covered by v142-managed-profile-handover.spec.ts)",
"/confirm-access": "the re-proof page, reached only from a stale session",
"/enroll-mfa": "the forced second-factor enrolment of a policy-bound account",
"/onboarding": "the first-run setup flow; covered by setup-flow-*.spec.ts",
"/onboarding/[step]":
"the first-run setup flow; covered by setup-flow-*.spec.ts",
};
type Resolver = (api: APIRequestContext) => Promise<string[]>;
async function readData<T>(api: APIRequestContext, path: string): Promise<T> {
const res = await api.get(path);
expect(res.ok(), `${path} answered ${res.status()}`).toBe(true);
return ((await res.json()) as { data: T }).data;
}
const firstId = (ids: Array<string | null | undefined>, what: string) => {
const id = ids.find((value): value is string => Boolean(value));
if (!id)
throw new Error(`the fixture holds no ${what} — globalSetup seeds one`);
return id;
};
/** Every dynamic route pattern under `src/app`, turned into real paths. */
const DYNAMIC_ROUTES: Record<string, Resolver> = {
"/settings/[section]": async () =>
SETTINGS_SECTION_SLUGS.map((slug) => `/settings/${slug}`),
"/admin/[section]": async () =>
ADMIN_SECTION_SLUGS.map((slug) => `/admin/${slug}`),
// `LAYOUT_GROUP_IDS` lives beside React components the test runner cannot
// load; `settings/layout/[module]` 404s an unknown group, so a stale entry
// here fails on the status check below rather than passing quietly. Mood
// has no arrangement page since v1.44: its old address redirects to
// `/settings/modules/mood`, which the entry below sweeps.
"/settings/layout/[module]": async () =>
[
"dashboard",
"insights",
"medications",
"labs",
"illness",
"documents",
"vorsorge",
].map((group) => `/settings/layout/${group}`),
"/settings/modules/[module]": async () =>
MODULE_SETTINGS_PAGE_IDS.map((module) => `/settings/modules/${module}`),
"/insights/scores/[metric]": async () =>
["sleep", "readiness", "recovery", "stress", "strain"].map(
(metric) => `/insights/scores/${metric}`,
),
"/insights/values/[type]": async () => [
"/insights/values/WEIGHT",
"/insights/values/BLOOD_PRESSURE_SYS",
],
"/labs/[biomarkerId]": async (api) => {
const data = await readData<{
results: Array<{ biomarkerId: string | null; analyte: string }>;
}>(api, "/api/labs?limit=50");
const id = firstId(
data.results
.filter((row) => row.analyte === MOBILE_ROUTES_ANALYTE)
.map((row) => row.biomarkerId),
"linked lab reading",
);
return [`/labs/${id}`];
},
"/labs/[biomarkerId]/values": async (api) =>
(await DYNAMIC_ROUTES["/labs/[biomarkerId]"](api)).map(
(path) => `${path}/values`,
),
"/medications/[id]": async (api) => {
const meds = await readData<Array<{ id: string }>>(api, "/api/medications");
return [
`/medications/${firstId(
meds.map((m) => m.id),
"medication",
)}`,
];
},
"/medications/[id]/history": async (api) =>
(await DYNAMIC_ROUTES["/medications/[id]"](api)).map(
(path) => `${path}/history`,
),
"/illness/[id]": async (api) => {
const episodes = await readData<Array<{ id: string }>>(
api,
"/api/illness/episodes",
);
return [
`/illness/${firstId(
episodes.map((e) => e.id),
"illness episode",
)}`,
];
},
"/custom-metrics/[id]": async (api) => {
const data = await readData<{ customMetrics: Array<{ id: string }> }>(
api,
"/api/custom-metrics",
);
const id = firstId(
data.customMetrics.map((m) => m.id),
"custom metric",
);
return [`/custom-metrics/${id}`];
},
"/custom-metrics/[id]/values": async (api) =>
(await DYNAMIC_ROUTES["/custom-metrics/[id]"](api)).map(
(path) => `${path}/values`,
),
"/insights/workouts/[id]": async (api) => {
const data = await readData<{ workouts: Array<{ id: string }> }>(
api,
"/api/workouts?limit=1",
);
return [
`/insights/workouts/${firstId(
data.workouts.map((w) => w.id),
"workout",
)}`,
];
},
// An ECG strip only arrives through a device sync; no route writes one. The
// detail page is swept in its not-found state, which is still the frame,
// header and back link a phone renders.
"/insights/ecg/[id]": async () => ["/insights/ecg/unknown-recording"],
};
/** Every `page.tsx` under `src/app`, as a route pattern (`/labs/[biomarkerId]`). */
function discoverRoutePatterns(dir = APP_DIR): string[] {
const patterns: string[] = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
if (entry === "api" || entry.startsWith("_") || entry === "__tests__")
continue;
patterns.push(...discoverRoutePatterns(full));
} else if (entry === "page.tsx") {
const rel = relative(APP_DIR, dir).split(sep).join("/");
// Route groups `(name)` do not appear in the URL.
const path = rel
.split("/")
.filter((segment) => segment && !/^\(.*\)$/.test(segment))
.join("/");
patterns.push(`/${path}`);
}
}
return patterns.sort();
}
const PATTERNS = discoverRoutePatterns();
const isDynamic = (pattern: string) => pattern.includes("[");
test.describe("every signed-in route fits a phone", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test.beforeEach(async ({ context }, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
const baseURL = testInfo.project.use.baseURL ?? "http://localhost:3000";
await context.addCookies([
{ name: "healthlog-locale", value: "de", url: baseURL },
]);
});
/**
* The bookkeeping the sweep depends on. A new dynamic route without a
* resolver, a resolver for a route that no longer exists, or an exclusion
* for a route that is gone each fail here by name.
*/
test("the swept routes are the routes the app has", () => {
expect(PATTERNS.length, "no page.tsx found under src/app").toBeGreaterThan(
50,
);
const unresolved = PATTERNS.filter(
(p) => isDynamic(p) && !NOT_SIGNED_IN[p] && !DYNAMIC_ROUTES[p],
);
expect(
unresolved,
"dynamic route(s) with no entry in DYNAMIC_ROUTES or NOT_SIGNED_IN",
).toEqual([]);
const stale = [
...Object.keys(DYNAMIC_ROUTES),
...Object.keys(NOT_SIGNED_IN),
].filter((p) => !PATTERNS.includes(p));
expect(stale, "entries for routes that no longer exist").toEqual([]);
});
for (const pattern of PATTERNS) {
if (NOT_SIGNED_IN[pattern]) continue;
test(`${pattern} does not scroll sideways at ${WIDTH}px`, async ({
page,
}) => {
test.slow();
const paths = isDynamic(pattern)
? await DYNAMIC_ROUTES[pattern](page.request)
: [pattern];
expect(paths.length, `${pattern} resolved to no path`).toBeGreaterThan(0);
await page.setViewportSize({ width: WIDTH, height: 844 });
for (const path of paths) {
const response = await page.goto(path, {
waitUntil: "domcontentloaded",
});
expect(
response?.status() ?? 0,
`${path} answered ${response?.status()}`,
).toBeLessThan(400);
// A redirect away (a module switched off, a missing record) would
// measure some other page and call it this one.
expect(new URL(page.url()).pathname, `${path} redirected`).toBe(
new URL(path, page.url()).pathname,
);
await settleForOverflowMeasurement(page);
await expectNoHorizontalOverflow(page, `${path} @${WIDTH}px`);
}
});
}
});
/**
* The lab surfaces, asserted at the elements that broke rather than only at
* the page width. The list's name column collapsed to nothing on a phone
* while the fixed columns beside it pushed the row off the card; the values
* table put its edit and delete buttons in a sideways-scrolling strip.
*/
test.describe("the lab surfaces stay readable on a phone", () => {
test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH });
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
await page.setViewportSize({ width: WIDTH, height: 844 });
});
test("the lab list shows each marker's name", async ({ page }) => {
await page.goto("/labs", { waitUntil: "domcontentloaded" });
const names = page.locator('[data-slot="lab-list-analyte"]');
await expect(names.first()).toBeVisible();
const widths = await names.evaluateAll((nodes) =>
nodes.map((node) => Math.round(node.getBoundingClientRect().width)),
);
expect(widths.length).toBeGreaterThan(1);
// A readable name, not the 0 px column the fixed columns left it.
for (const width of widths) expect(width).toBeGreaterThan(60);
});
test("the values list keeps its actions on screen", async ({ page }) => {
const [path] = await DYNAMIC_ROUTES["/labs/[biomarkerId]/values"](
page.request,
);
await page.goto(path, { waitUntil: "domcontentloaded" });
const list = page.locator('[data-slot="lab-history-mobile-list"]');
await expect(list).toBeVisible();
await expect(page.locator('[data-slot="lab-history-table"]')).toBeHidden();
const buttons = list.locator("button");
expect(await buttons.count()).toBeGreaterThan(1);
const outside = await buttons.evaluateAll(
(nodes) =>
nodes
.map((node) => node.getBoundingClientRect())
.filter((r) => r.left < 0 || r.right > window.innerWidth + 1).length,
);
expect(outside, "a reading's action sits off-screen").toBe(0);
});
});