Files
HealthLog/e2e/mobile-viewport.spec.ts
T
Marc-André Bombeck 40c25038a5 fix(onboarding): lift the checklist toggle + dismiss button to the 44 px mobile floor
The v1.4.34.5 tap-target sweep covered the dashboard tile and
per-row chevron but missed the two CTAs the
`<GettingStartedChecklist>` header renders on the onboarded-user
dashboard: the expand/collapse toggle (32 px tall) and the
dismiss-all CTA (40 px via shadcn's default Button, which has not
yet learned the `h-11 sm:h-10` mobile lift the input primitives
follow). Both flagged in the `mobile-viewport` touch-target probe.
Pin `min-h-11 sm:min-h-10` on each so the desktop rhythm is
unchanged.

While here, two e2e specs needed updates to match the same wave:

- `mobile-viewport.spec.ts` enforced `width < 44 || height < 44`,
  which flagged the chart-range pill row (`7T` / `30T` / `90T`,
  each 44 px tall but 30-40 px wide) as a WCAG violation even
  though 2.5.5 honours adjacent-target spacing for horizontal
  groups. Drop the width branch; the height floor is the hard
  contract every solo CTA must clear.

- `onboarding-flicker.spec.ts` route-stubbed `**/api/analytics`,
  which is exact-match against the path — the IW1 slim-slice
  variant `/api/analytics?slice=summaries` (which the checklist
  hook now uses) bypassed the stub and hit the real route, so the
  onboarded-user fixture flipped to `measurementCount = 0` and
  the card rendered. Swap both stubs to a regex that matches the
  base path AND any query-string variant.
2026-05-17 14:53:04 +02:00

167 lines
6.7 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 "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* 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.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" });
await page.waitForLoadState("networkidle");
// 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([]);
});
});