Files
HealthLog/e2e/mobile-viewport.spec.ts
T
Marc-André Bombeck 4335b38e02 feat(nutrients): remove in-app water logging, keep the sync path
Manual water entry is gone from the app: the water item on the capture
picker, the dashboard quick-add menu and the hydration card's add button
are removed, and the manual quick-add sheet is deleted. Water that syncs
in through Apple Health still arrives and still shows on the hydration
card with its daily total, history and reference line.

The POST /api/nutrients/water endpoint and the batch sync path stay
untouched. The nutrients page's module-enable error, which had borrowed
a water-quick-add string, gets its own key across all six locales.
Input-only water keys (nav.capture.water*, nutrients.hydration.quickAdd*)
are removed.
2026-08-09 06:46:28 +02:00

228 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
});