diff --git a/e2e/dialog-horizontal-overflow.spec.ts b/e2e/dialog-horizontal-overflow.spec.ts index 9a66f45f2..09eacf398 100644 --- a/e2e/dialog-horizontal-overflow.spec.ts +++ b/e2e/dialog-horizontal-overflow.spec.ts @@ -97,6 +97,10 @@ const DIALOGS: DialogCase[] = [ const VIEWPORTS = [ { label: "desktop 1280", width: 1280, height: 800 }, { label: "phone 390", width: 390, height: 844 }, + // The narrowest common Android width. The labs sheet's three-button footer + // (Cancel, Save & add another, Save) fits 390 in English and pushed Cancel + // off the left edge in German; 360 is where English hits the same wall. + { label: "phone 360", width: 360, height: 780 }, ]; /** diff --git a/e2e/mobile-route-overflow.spec.ts b/e2e/mobile-route-overflow.spec.ts new file mode 100644 index 000000000..2529cacdf --- /dev/null +++ b/e2e/mobile-route-overflow.spec.ts @@ -0,0 +1,311 @@ +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 { SETTINGS_SECTION_SLUGS } from "@/components/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 = { + "/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", + "/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; + +async function readData(api: APIRequestContext, path: string): Promise { + 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, 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 = { + "/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. + "/settings/layout/[module]": async () => + [ + "dashboard", + "insights", + "medications", + "mood", + "labs", + "illness", + "vorsorge", + ].map((group) => `/settings/layout/${group}`), + "/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>(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>( + 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); + }); +}); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 550980d24..6f28780bb 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -33,6 +33,8 @@ import { } from "@playwright/test"; import pg from "pg"; +import { seedMobileRoutesRecord } from "./mobile-routes-fixture"; + // Resolve relative to the project root rather than __dirname/import.meta.url — // Playwright's TS loader runs files as CJS without `"type":"module"`, and // loading either dirname helper at module scope crashes the test runner. @@ -191,6 +193,27 @@ export const MODULES_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateModules.json", ); +/** + * The phone-width route sweep's own account (`mobile-route-overflow.spec.ts`). + * + * It holds one or two of everything a route can render + * (`mobile-routes-fixture.ts`), because the sweep is only worth something + * against populated pages, and writing that much into the shared account would + * change what every other spec reads. An administrator, so the admin console + * is swept too; `FEMALE`, so the cycle surfaces render without a switch. + */ +export const E2E_MOBILE_ROUTES = { + email: "e2e-mobile-routes@healthlog.test", + username: "e2e-mobile-routes", + password: "Wd7!Kp3mTz9qRv2N", + role: "ADMIN", +} as const; + +export const MOBILE_ROUTES_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateMobileRoutes.json", +); + /** * The AI-optional journeys' own account (`ai-optional-*.spec.ts`). * @@ -823,6 +846,43 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // The phone-width route sweep's account. Every opt-in module is on, so + // the sweep reaches the glucose, documents, wellbeing, nutrients and + // environment surfaces; its record is written after the login capture. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + height_cm, date_of_birth, gender, + onboarding_completed_at, onboarding_tour_completed, + module_preferences_json) + VALUES ($1, $2, $3, $4, $5, $6, $6, 168, $7, 'FEMALE', $6, true, + $8::jsonb) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + role = EXCLUDED.role, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = EXCLUDED.module_preferences_json`, + [ + cuid(), + E2E_MOBILE_ROUTES.username, + E2E_MOBILE_ROUTES.email, + await hashPassword(E2E_MOBILE_ROUTES.password), + E2E_MOBILE_ROUTES.role, + now, + new Date("1988-04-12T00:00:00.000Z"), + JSON.stringify({ + glucose: true, + environment: true, + inboundDocuments: true, + mentalHealth: true, + nutrients: true, + }), + ], + ); + // The AI-optional journeys' account. Its provider, stored briefing, // consent and Coach rows are set by `e2e/setup/ai-optional-fixture.ts` // before each journey. @@ -1441,6 +1501,11 @@ export default async function globalSetup(config: FullConfig): Promise { // The module-surfaces journey's jar — see `E2E_MODULES`. await capture(E2E_MODULES, MODULES_STORAGE_STATE_PATH); + // The phone-width route sweep's jar, then its record — see + // `E2E_MOBILE_ROUTES`. + await capture(E2E_MOBILE_ROUTES, MOBILE_ROUTES_STORAGE_STATE_PATH); + await seedMobileRoutesRecord(baseURL, MOBILE_ROUTES_STORAGE_STATE_PATH); + // The AI-optional journeys' jar — see `E2E_AI_OPTIONAL`. await capture(E2E_AI_OPTIONAL, AI_OPTIONAL_STORAGE_STATE_PATH); diff --git a/e2e/setup/mobile-routes-fixture.ts b/e2e/setup/mobile-routes-fixture.ts new file mode 100644 index 000000000..0213adbd5 --- /dev/null +++ b/e2e/setup/mobile-routes-fixture.ts @@ -0,0 +1,204 @@ +/** + * The record `mobile-route-overflow.spec.ts` measures. + * + * A phone-width overflow guard that visits an empty page proves nothing: an + * empty list fits any viewport, and the lab list that panned sideways on a + * phone only did so once it held a reading. So the account gets one or two of + * everything a signed-in route can render — readings with reference ranges, a + * medication, a mood entry, a visit, a vaccination, an illness episode, a + * custom metric, a workout, a document — and every dynamic route has a real id + * to open. + * + * Written through the app's own routes, once, from `globalSetup`, so the + * parallel workers of the spec never race each other to seed it. An account + * that already holds a lab reading is taken as seeded, which keeps a local + * re-run against a reused database from doubling every row. + */ +import { request as playwrightRequest } from "@playwright/test"; + +/** Long on purpose: the names are what a narrow column has to give way to. */ +export const MOBILE_ROUTES_ANALYTE = "Glomeruläre Filtrationsrate (eGFR)"; + +export async function seedMobileRoutesRecord( + baseURL: string, + storageStatePath: string, +): Promise { + const ctx = await playwrightRequest.newContext({ + baseURL, + storageState: storageStatePath, + }); + try { + // The document is the last thing written, so its presence means a + // complete record. A run that died half-way is finished by the next one: + // a row it already wrote answers 409 and is skipped. + const existing = await ctx.get("/api/documents/inbound"); + const existingBody = (await existing.json()) as { + data: { documents?: unknown[] } | unknown[] | null; + }; + const documents = Array.isArray(existingBody.data) + ? existingBody.data + : (existingBody.data?.documents ?? []); + if (documents.length > 0) return; + + const post = async (path: string, data: unknown): Promise => { + const res = await ctx.post(path, { data }); + if (res.status() === 409) return null; + if (!res.ok()) { + throw new Error( + `[mobile-routes-fixture] ${path} answered ${res.status()}: ${(await res.text()).slice(0, 200)}`, + ); + } + return ((await res.json()) as { data: unknown }).data; + }; + const daysAgo = (days: number, hour = 8) => { + const at = new Date(Date.now() - days * 86_400_000); + at.setUTCHours(hour, 0, 0, 0); + return at.toISOString(); + }; + + for (let day = 1; day <= 6; day += 1) { + await post("/api/measurements", { + type: "WEIGHT", + value: 80 + day / 10, + measuredAt: daysAgo(day, 7), + }); + await post("/api/measurements", { + type: "BLOOD_PRESSURE_SYS", + value: 120 + day, + measuredAt: daysAgo(day, 7), + }); + await post("/api/measurements", { + type: "BLOOD_PRESSURE_DIA", + value: 78 + (day % 3), + measuredAt: daysAgo(day, 7), + }); + await post("/api/measurements", { + type: "PULSE", + value: 60 + day, + measuredAt: daysAgo(day, 7), + }); + await post("/api/measurements", { + type: "BLOOD_GLUCOSE", + value: 92 + day, + glucoseContext: "FASTING", + measuredAt: daysAgo(day, 6), + }); + await post("/api/mood-entries", { + mood: day % 2 ? "GUT" : "OKAY", + moodLoggedAt: daysAgo(day, 20), + }); + } + + // Two readings of one marker, so the list row has a trend line and a + // range bar, plus a qualitative one with neither. + for (const [days, value] of [ + [40, 78], + [5, 84], + ] as const) { + await post("/api/labs", { + analyte: MOBILE_ROUTES_ANALYTE, + panel: "Nierenwerte und Elektrolyte", + value, + unit: "mL/min/1.73m²", + referenceLow: 60, + referenceHigh: 120, + takenAt: daysAgo(days), + }); + } + await post("/api/labs", { + analyte: "Hepatitis-B-Oberflächenantigen", + valueText: "negativ", + takenAt: daysAgo(5), + }); + + await post("/api/medications", { + name: "Ramipril", + dose: "5 mg", + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }); + + const practitioner = (await post("/api/practitioners", { + name: "Gemeinschaftspraxis für Allgemeinmedizin", + specialty: "Allgemeinmedizin und Innere Medizin", + })) as { id: string } | null; + await post("/api/encounters", { + occurredAt: daysAgo(20), + practitionerId: practitioner?.id ?? null, + reason: "Routinekontrolle Blutdruck und Blutbild", + }); + await post("/api/vaccinations", { + occurredAt: daysAgo(300), + antigenSlug: "tetanus", + doseNumber: 1, + lotNumber: "AB1234-XY", + practitionerId: practitioner?.id ?? null, + }); + + await post("/api/illness/episodes", { + label: "Erkältung mit Husten", + type: "INFECTION", + onsetAt: daysAgo(12), + resolvedAt: daysAgo(6), + }); + + const metric = (await post("/api/custom-metrics", { + name: "Augeninnendruck rechts", + unit: "mmHg", + targetLow: 10, + targetHigh: 21, + decimals: 0, + })) as { id: string } | null; + const metricId = + metric?.id ?? + ( + (await (await ctx.get("/api/custom-metrics")).json()) as { + data: { customMetrics: Array<{ id: string }> }; + } + ).data.customMetrics[0]?.id; + await post(`/api/custom-metrics/${metricId}/entries`, { + value: 16, + measuredAt: daysAgo(3), + }); + + await post("/api/workouts/batch", { + workouts: [ + { + sportType: "running", + startedAt: daysAgo(2, 6), + endedAt: new Date( + new Date(daysAgo(2, 6)).getTime() + 45 * 60_000, + ).toISOString(), + source: "APPLE_HEALTH", + externalId: "e2e-mobile-routes-run", + activeEnergyKcal: 420, + distanceM: 6500, + }, + ], + }); + + const pdf = + "%PDF-1.4\n%e2e-mobile-routes\n" + + "1 0 obj<>endobj\n" + + "2 0 obj<>endobj\n" + + "3 0 obj<>endobj\n" + + "xref\n0 4\ntrailer<>\n%%EOF\n"; + const doc = await ctx.post("/api/documents/inbound", { + multipart: { + file: { + name: "laborbefund-hausarzt-mit-langem-dateinamen.pdf", + mimeType: "application/pdf", + buffer: Buffer.from(pdf), + }, + title: "Laborbefund Hausarzt mit einem sehr langen Titel", + kind: "LAB_RESULT", + }, + }); + if (!doc.ok()) { + throw new Error( + `[mobile-routes-fixture] /api/documents/inbound answered ${doc.status()}`, + ); + } + } finally { + await ctx.dispose(); + } +}