import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { mockDashboardSnapshot, WEIGHT_ONLY_SUMMARIES, } from "./utils/mock-dashboard-snapshot"; /** * v1.4.19 A6 — Settings consistency snapshot at Pixel-5 (393 CSS px). * v1.4.27 — input height floor lifted from 36 px (`h-9`) to 40 px * (`h-10`) on `Input`, `Select` trigger, and the native `, and the formatted overlay that fronts the // sr-only native inside DateField / // DateTimeField. The touch-target sweep must measure those visible // affordances, not the hidden input, so exempt any input that is // visually hidden or collapsed to zero size. (inp) => !(inp.hidden || inp.height === 0 || inp.width === 0), ); expect(formInputs.length).toBeGreaterThan(0); for (const inp of formInputs) { expect.soft(inp.height, `${inp.tag}#${inp.id} (${inp.type})`).toBe(44); } }); test("/settings/security: action buttons do not overflow their cards", async ({ page, }) => { // The change-password card sits on Security now, beside the second // factors and the passkeys. The claim is unchanged; the address is not. await page.goto("/settings/security", { waitUntil: "networkidle" }); // Same reason as the grid test below: gate on the element, not the // network, because the read underneath it happens exactly once. await expect( page.getByRole("button", { name: /change password/i }), ).toBeVisible(); // The page's action button(s) must live within (or above the bottom of) // the parent card. On mobile the button stacks below the title — it is // allowed to extend the card's height, but not push past the right edge. // The German "Passwort ändern" is the long case this was written for. const overflowCheck = await page.evaluate(() => { const buttons = Array.from(document.querySelectorAll("button")); const targets = buttons.filter((b) => { const t = (b.textContent || "").trim().toLowerCase(); return ( t.includes("change password") || t.includes("passwort ändern") || t.includes("restart onboarding") || t.includes("onboarding-tour") ); }); return targets.map((b) => { const card = b.closest(".bg-card"); const cardRect = card?.getBoundingClientRect(); const btnRect = b.getBoundingClientRect(); return { text: (b.textContent || "").trim(), btnRight: Math.round(btnRect.right), cardRight: cardRect ? Math.round(cardRect.right) : null, }; }); }); expect( overflowCheck.length, "expected to find the change-password action button on /settings/security", ).toBeGreaterThanOrEqual(1); for (const t of overflowCheck) { expect(t.cardRight, `card for "${t.text}"`).not.toBeNull(); // 1 px tolerance for sub-pixel rounding. expect .soft( t.btnRight, `"${t.text}" right=${t.btnRight} must stay within card right=${t.cardRight}`, ) .toBeLessThanOrEqual((t.cardRight ?? 0) + 1); } }); test("/settings/account: Sprache select shares one grid row with date-of-birth", async ({ page, }) => { await page.goto("/settings/account", { waitUntil: "networkidle" }); // Gate on the two elements this test is about, not on the network. An idle // network says nothing about whether React has rendered the profile form, // and the single `evaluate` below does not retry — so without this the // test reads an empty document and reports "language + dob fields must // exist", which sounds like a missing feature and is really a race. await expect(page.locator("#language-select")).toBeAttached(); await expect(page.locator("#dob")).toBeAttached(); // The v1.4.27 R1 settings audit pairs date-of-birth with language // in a single `grid sm:grid-cols-2` row so the profile form keeps // a uniform two-column rhythm and the language field no longer // sits alone at the bottom with a `sm:max-w-xs` clamp. const sharedGrid = await page.evaluate(() => { const lang = document.getElementById("language-select"); const dob = document.getElementById("dob"); if (!lang || !dob) return { found: false, sharedGrid: false }; const langGrid = lang.closest('[class*="grid"]'); const dobGrid = dob.closest('[class*="grid"]'); return { found: true, sharedGrid: langGrid !== null && langGrid === dobGrid, }; }); expect(sharedGrid.found, "language + dob fields must exist").toBe(true); expect(sharedGrid.sharedGrid, "language + dob must share a grid").toBe( true, ); }); test("/: Compare-to trigger renders at 44 px", async ({ page }) => { // v1.34.0 — the dashboard-settings comparison-baseline control was // removed; the same preference now lives behind the per-chart overlay // popover (see chart-overlay-controls.spec.ts for the full open/toggle // flow). That popover only paints once a chart with a real chartKey // renders, so this test needs the same seeded-chart mocks that spec // uses rather than the real (likely-empty) e2e database. await mockDashboardSnapshot(page, { summaries: WEIGHT_ONLY_SUMMARIES }); await page.route(/\/api\/analytics(\?|$)/, (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ data: { summaries: { WEIGHT: { latest: 78.5, avg7: 78.2, avg30: 77.9, slope30: { slope: -0.05, direction: "down" }, count: 30, }, }, bpInTargetPct: 0, glucoseByContext: {}, }, error: null, }), }), ); await page.route("**/api/mood/analytics", (route) => route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ data: { entries: [], summary: { count: 0 } }, error: null, }), }), ); await page.route("**/api/measurements*", (route) => { const measurements = Array.from({ length: 10 }, (_, i) => ({ id: `m_${i}`, type: "WEIGHT", value: 78 + (i % 3) - 1, measuredAt: new Date(Date.now() - i * 86_400_000).toISOString(), notes: null, })); return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ data: { measurements, meta: { total: measurements.length } }, error: null, }), }); }); await page.goto("/", { waitUntil: "domcontentloaded" }); await expect( page.locator('[data-slot="dashboard-tile-strip"]'), ).toBeVisible({ timeout: 10_000 }); const trigger = page .locator('[data-slot="chart-overlay-controls-trigger"]') .first(); await expect(trigger).toBeVisible({ timeout: 10_000 }); await trigger.scrollIntoViewIfNeeded(); await trigger.click(); // `data-slot="chart-overlay-comparison-baseline"` is the grid WRAPPER // (line 235 in chart-overlay-controls.tsx); the buttons themselves are // `data-slot="chart-overlay-comparison-{none|lastMonth|lastYear}"`. // Match only the wrapper's children so the wrapper's own (differently // sized) box never gets measured as if it were a tap target. const baselineButton = page .locator( '[data-slot="chart-overlay-comparison-baseline"] [data-slot^="chart-overlay-comparison-"]', ) .first(); await expect(baselineButton).toBeVisible({ timeout: 5_000 }); // `min-h-11` sets the CSS contract (`min-height: 44px`), the same // guarantee `h-11` gives the other 44px checks in this spec. This // element additionally carries an explicit `h-8` from the Button "sm" // size variant that `min-height` overrides; on Playwright's Pixel 5 // profile (fractional deviceScaleFactor) that combination measures a // `getBoundingClientRect().height` a device pixel or two under 44 even // though the computed CSS is exactly 44px — a viewport-emulation // rounding artifact, not a real layout regression. Assert the // authoritative computed style instead of the physically-snapped rect. const minHeight = await baselineButton.evaluate( (el) => getComputedStyle(el).minHeight, ); expect(minHeight).toBe("44px"); }); test("/settings/ai: every native select renders at 44 px", async ({ page, }) => { await page.goto("/settings/ai", { waitUntil: "networkidle" }); // `evaluateAll` over an empty match set returns an empty array rather than // waiting, so the count assertion below would be the thing that fails and // it would blame the page for having no selects. Wait for the first one. await expect(page.locator("section select").first()).toBeVisible(); const heights = await page .locator("section select") .evaluateAll((els) => els.map((el) => Math.round(el.getBoundingClientRect().height)), ); expect(heights.length).toBeGreaterThan(0); for (const h of heights) { expect.soft(h).toBe(44); } }); });