From 1340200cac08401dc54e4fdf9504e7421af0d90b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 09:58:14 +0100 Subject: [PATCH] test(e2e): hold the header height in every app language A translated label that runs a little longer than the English one wraps, and a wrapping header grows; every English-only check stays green while one locale ships a taller dashboard header. The new sweep switches its own account through each locale the app ships (via the locale API, with asserted before measuring), then compares the top bar and the dashboard's page-header row on a 390 px phone and a desktop window with the height English gives them, within 1 px. The locale list is read from the i18n config, so a new language is swept without editing the spec. It runs in the desktop project only because the account's language is one value shared by its tests. --- e2e/header-height-locales.spec.ts | 161 ++++++++++++++++++++++++++++++ e2e/setup/global-setup.ts | 49 +++++++++ playwright.config.ts | 5 + 3 files changed, 215 insertions(+) create mode 100644 e2e/header-height-locales.spec.ts diff --git a/e2e/header-height-locales.spec.ts b/e2e/header-height-locales.spec.ts new file mode 100644 index 000000000..e1697963a --- /dev/null +++ b/e2e/header-height-locales.spec.ts @@ -0,0 +1,161 @@ +/** + * The app header keeps its height in every language. + * + * A translated label that is a little longer than the English one wraps, and + * a header that wraps grows: the dashboard's title row once took a second + * line in one locale while every English check stayed green. The top bar and + * the dashboard's page-header row are measured here in every shipped locale, + * on a 390 px phone and on a desktop window, against the height English gives + * them on the same viewport. + * + * The language is switched the way the app switches it, through + * `PUT /api/auth/me/locale`, which writes the account's language and the + * locale cookie; `` is then asserted before anything is measured, + * so a switch that silently did not happen cannot pass as "same height". + * + * The locale list is the app's own (`src/lib/i18n/config.ts`), so a language + * added there is swept without touching this file. + */ +import type { Page } from "@playwright/test"; + +import { locales, type Locale } from "@/lib/i18n/config"; + +import { HEADER_LOCALES_STORAGE_STATE_PATH } from "./setup/global-setup"; +import { expect, test } from "./setup/test"; + +const VIEWPORTS = [ + { label: "phone 390", width: 390, height: 844 }, + { label: "desktop 1280", width: 1280, height: 800 }, +] as const; + +type ViewportLabel = (typeof VIEWPORTS)[number]["label"]; + +interface HeaderGeometry { + topBar: number; + pageHeader: number; + /** For the failure message: what wrapped, not only that something did. */ + title: string; + titleHeight: number; +} + +/** English first: every other locale is compared with what it measured. */ +const ORDER: Locale[] = ["en", ...locales.filter((l) => l !== "en")]; + +const reference = new Map(); + +async function readHeader(page: Page): Promise { + return page.evaluate(() => { + const topBar = document.querySelector('[data-slot="top-bar"]'); + const rows = document.querySelectorAll( + 'main [data-slot="page-header-row"]', + ); + if (!topBar || rows.length !== 1) return null; + const row = rows[0]; + const title = row.querySelector("h1"); + return { + topBar: topBar.getBoundingClientRect().height, + pageHeader: row.getBoundingClientRect().height, + title: title?.textContent ?? "", + titleHeight: title?.getBoundingClientRect().height ?? 0, + }; + }); +} + +/** + * Read the header until two reads a frame apart agree. The greeting swaps to + * the personalised line after hydration; measuring mid-swap would compare a + * frame nobody keeps. + */ +async function settledHeader(page: Page): Promise { + await page.evaluate(() => document.fonts.ready.then(() => undefined)); + let previous: HeaderGeometry | null = null; + for (let attempt = 0; attempt < 20; attempt += 1) { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ), + ); + const current = await readHeader(page); + if ( + current && + previous && + current.topBar === previous.topBar && + current.pageHeader === previous.pageHeader + ) { + return current; + } + previous = current; + } + throw new Error( + `header did not settle: ${JSON.stringify(previous ?? "not found")}`, + ); +} + +test.describe("header height in every locale", () => { + test.use({ storageState: HEADER_LOCALES_STORAGE_STATE_PATH }); + // The account's language is one value; the locales take turns on it. + // It runs in the desktop project only (see `playwright.config.ts`) and sets + // both viewports itself. + test.describe.configure({ mode: "serial" }); + + for (const locale of ORDER) { + test(`top bar and dashboard header keep the English height in ${locale}`, async ({ + page, + }) => { + test.setTimeout(60_000); + + const switched = await page.request.put("/api/auth/me/locale", { + data: { locale }, + }); + expect(switched.status(), await switched.text()).toBe(200); + + for (const viewport of VIEWPORTS) { + await page.setViewportSize({ + width: viewport.width, + height: viewport.height, + }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.locator("html")).toHaveAttribute("lang", locale); + await expect(page.locator('[data-slot="top-bar"]')).toBeVisible(); + await expect( + page.locator('main [data-slot="page-header-row"]'), + ).toBeVisible(); + await expect( + page.locator('[data-slot="dashboard-header-greeting"]'), + ).toBeVisible(); + await expect + .poll(() => page.evaluate(() => window.innerWidth)) + .toBe(viewport.width); + + const measured = await settledHeader(page); + const at = `${locale}, ${viewport.label}`; + // A band that is not there measures 0 in every locale and "agrees". + expect( + measured.topBar, + `top bar has no height (${at})`, + ).toBeGreaterThan(32); + expect( + measured.pageHeader, + `page header has no height (${at})`, + ).toBeGreaterThan(20); + + if (locale === "en") { + reference.set(viewport.label, measured); + continue; + } + const english = reference.get(viewport.label); + expect(english, "the English reference ran first").toBeDefined(); + const detail = `title "${measured.title}" ${measured.titleHeight}px tall vs "${english!.title}" ${english!.titleHeight}px`; + expect( + Math.abs(measured.topBar - english!.topBar), + `top bar is ${measured.topBar}px in ${at}, ${english!.topBar}px in English`, + ).toBeLessThanOrEqual(1); + expect( + Math.abs(measured.pageHeader - english!.pageHeader), + `dashboard header is ${measured.pageHeader}px in ${at}, ${english!.pageHeader}px in English; ${detail}`, + ).toBeLessThanOrEqual(1); + } + }); + } +}); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 5a749b7fc..355535f32 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -325,6 +325,25 @@ export const SCORE_HISTORY_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateScoreHistory.json", ); +/** + * The header-height sweep's own account (`header-height-locales.spec.ts`). + * + * It moves the account's language through every shipped locale; the server + * honours `User.locale` wherever a request carries no locale cookie, so on a + * shared account a parallel spec could render in Korean halfway through. + */ +export const E2E_HEADER_LOCALES = { + email: "e2e-header-locales@healthlog.test", + username: "e2e-header-locales", + password: "Hl8!Tq2vNx6mWr4Z", + role: "USER", +} as const; + +export const HEADER_LOCALES_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateHeaderLocales.json", +); + export const AI_OPTIONAL_STORAGE_STATE_PATH = resolve( process.cwd(), "e2e/setup/storageStateAiOptional.json", @@ -1102,6 +1121,33 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // The header-height sweep's account. Default modules and layout, so the + // dashboard header carries what a fresh account's does; the language is + // put back to English because the sweep leaves it on its last locale. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed, + module_preferences_json, locale) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, NULL, 'en') + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = NULL, + dashboard_widgets_json = NULL, + locale = 'en'`, + [ + cuid(), + E2E_HEADER_LOCALES.username, + E2E_HEADER_LOCALES.email, + await hashPassword(E2E_HEADER_LOCALES.password), + now, + ], + ); + // The notification-dispatch journey's account. Seeded like the others; // its channels, devices, ledger rows and preferences are reset by // `e2e/setup/notification-fixture.ts` before every test, because the @@ -1784,6 +1830,9 @@ export default async function globalSetup(config: FullConfig): Promise { // The score-history journey's jar — see `E2E_SCORE_HISTORY`. await capture(E2E_SCORE_HISTORY, SCORE_HISTORY_STORAGE_STATE_PATH); + // The header-height sweep's jar — see `E2E_HEADER_LOCALES`. + await capture(E2E_HEADER_LOCALES, HEADER_LOCALES_STORAGE_STATE_PATH); + await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH); diff --git a/playwright.config.ts b/playwright.config.ts index 4fbee7e5f..c046e2d21 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -310,6 +310,11 @@ export default defineConfig({ // other. It checks the phone width by resizing inside the desktop // project. "score-history.spec.ts", + // The header-height sweep moves its account's language through every + // locale; a second project would switch it under the first between + // the switch and the measurement. It sets the 390 px phone width + // itself inside the desktop project. + "header-height-locales.spec.ts", ], use: { // Pixel 5 — Chromium-based mobile profile so CI only needs