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