import { expect, test } from "./setup/test"; import { STORAGE_STATE_PATH } from "./setup/global-setup"; import { settleBeforeMeasure } from "./utils/settle"; import { mockDashboardSnapshot, WEIGHT_ONLY_SUMMARIES, } from "./utils/mock-dashboard-snapshot"; /** * Mobile-viewport smoke (Pixel 5 profile only). Asserts: * * 1. No horizontal scroll — `documentElement.scrollWidth` must NOT * exceed the viewport width by more than the standard 1-pixel * sub-pixel rounding tolerance. * 2. The fixed bottom-nav doesn't visually clobber main content — * asserted by sampling the last visible interactive element in * the dashboard's main region and checking it's above the nav. * 3. Every interactive CTA visible in the initial viewport meets * the WCAG 2.5.5 mobile tap-target height floor (44 CSS px). * Width is not floor-checked: the chart-range pill row keeps * tabs in the 30-40 px width band by design so the strip fits * Pixel-5; WCAG 2.5.5 honours adjacent-target spacing for * horizontal groups. * * Runs only on the `chromium-mobile` project (Pixel 5). The desktop * project skips this whole describe block. */ test.describe("mobile-viewport smoke", () => { test.use({ storageState: STORAGE_STATE_PATH }); test.beforeEach(({}, testInfo) => { test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec"); }); test("dashboard has no horizontal scroll, bottom-nav respects content, all CTAs ≥ 44 px tall", async ({ page, }) => { // v1.7.2 — snapshot flag default-ON; mock the snapshot cell with a // WEIGHT-only populated summary so the weight tile paints. Legacy // mocks below stay for the reversible `=false` path. await mockDashboardSnapshot(page, { summaries: WEIGHT_ONLY_SUMMARIES }); // v1.4.37 W-CI — match `/api/analytics` AND any sliced variant // (`?slice=summaries`). The previous string glob `**/api/analytics` // didn't match the query-string form so the IW1 slim-slice fetch // landed on the unmocked real route in CI. 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, direction: "flat" }, count: 30, }, }, bpInTargetPct: null, 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.goto("/", { waitUntil: "domcontentloaded" }); // Gate on the dashboard's own content, not on the network. Every read // below is geometric and happens exactly once, so measuring a tree that // is still mounting reports a layout nobody will ever see. await settleBeforeMeasure(page, page.locator("main#main-content")); // 1) No horizontal scroll const dims = await page.evaluate(() => ({ scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, innerWidth: window.innerWidth, })); // Allow 1 sub-pixel tolerance. expect( dims.scrollWidth, `scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`, ).toBeLessThanOrEqual(dims.innerWidth + 1); // 2) Bottom-nav doesn't overlap content. The nav is `fixed bottom-0` // and `md:hidden`, so on the Pixel 5 viewport it's visible. Pick // the dashboard heading as our content sentinel — it sits at the // top of the page, well above the nav, so an overlap (== nav over // heading) would be a regression. We separately confirm the nav // itself sits at the bottom of the visual viewport. const navBox = await page .locator("nav[aria-label]") .filter({ has: page.locator("a[href='/']") }) .first() .boundingBox(); if (navBox) { const viewportHeight = page.viewportSize()?.height ?? 0; // The nav's top must be within the viewport (bottom-anchored) expect(navBox.y).toBeGreaterThan(0); expect(navBox.y + navBox.height).toBeLessThanOrEqual(viewportHeight + 1); } // 3) All visible CTAs ≥ 44×44. // // Selector scope intentionally limited to `main` content. The // bottom-nav owns its own WCAG enforcement (a dedicated spec // covers the fixed nav and the brand-link cluster), so sweeping // `nav a[href]` here only added flake on the Pixel 5 boundary // where viewport-detection sometimes flipped during the WebKit // render commit. The 44-px floor is also conditional on a true // mobile breakpoint: if a CSS `(min-width: 640px)` media query // matches (the `sm:` tier is in scope), the dashboard CTAs may // intentionally shrink to a tighter target size. const isPhoneWidth = await page.evaluate( () => !window.matchMedia("(min-width: 640px)").matches, ); if (!isPhoneWidth) { // Desktop breakpoint somehow active on the Pixel 5 viewport; // skip the floor — different design rules apply. return; } const buttons = await page.locator("main button, main a[href]").all(); const failures: string[] = []; const viewportH = page.viewportSize()?.height ?? 0; for (const btn of buttons) { // Skip elements that aren't visible at all. const isVisible = await btn.isVisible().catch(() => false); if (!isVisible) continue; const box = await btn.boundingBox(); if (!box) continue; // Only assert against elements actually in the initial viewport // (above the fold). Off-screen items are fine — the user has to // scroll to interact, which is its own UX problem the design // review handles. if (box.y < 0 || box.y > viewportH) continue; // v1.4.37 W-CI — enforce the WCAG 2.5.5 height floor only. // The original `width < 44 || height < 44` check flagged // grouped compact controls (chart-range tabs like "7T"/"30T" // /"90T" sitting at 44 px tall but ~30-40 px wide) as // violations even though WCAG 2.5.5 treats horizontally // adjacent siblings with adequate spacing as compliant. The // primary touch hazard on mobile is vertical mis-tap, so a 44 px // height floor is the contract every solo button must clear; // grouped pill rows handle width via gap-based spacing instead. if (box.height < 44) { // Prefer the accessible name when available; fall back to the // trimmed text. A role-based label is more stable across // re-skins than the raw `innerText` that brittle selectors // surfaced previously. const ariaLabel = await btn .getAttribute("aria-label") .catch(() => null); const innerText = await btn.innerText().catch(() => ""); const label = ariaLabel || innerText || "(no text)"; failures.push( `${label.slice(0, 40)} → ${box.width.toFixed(1)}×${box.height.toFixed(1)}`, ); } } expect( failures, `Touch-targets below 44 px tall:\n ${failures.join("\n ")}`, ).toEqual([]); }); test("center capture action opens the picker; the More hub keeps Mood + Measurements reachable", async ({ page, }) => { 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: {}, bpInTargetPct: null, 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.goto("/", { waitUntil: "domcontentloaded" }); await page.waitForLoadState("networkidle"); // 1) The center capture action opens the capture picker. await page.getByTestId("bottom-nav-capture").click(); await expect(page.getByTestId("capture-picker-options")).toBeVisible(); for (const kind of ["measurement", "medication", "mood"]) { await expect(page.getByTestId(`capture-picker-${kind}`)).toBeVisible(); } // Dismiss the picker before opening the hub. await page.keyboard.press("Escape"); // 2) The More hub keeps Mood + Measurements reachable (additive // middle-path — they left the strip but are NOT orphaned). await page.getByTestId("bottom-nav-more").click(); const hub = page.getByTestId("bottom-nav-more-sheet"); await expect(hub).toBeVisible(); await expect(hub.locator("a[href='/measurements']")).toBeVisible(); await expect(hub.locator("a[href='/mood']")).toBeVisible(); }); });