Files
HealthLog/e2e/mobile-viewport.spec.ts
T
Marc-André Bombeck fef24a89bb test(e2e): stabilise onboarding-flicker + mobile-viewport probes
Three pre-existing flakes in the v1.4.33 suite were carrying through to
v1.4.34 as 113 passed / 3 failed. Both specs needed tighter waits and
selectors:

- onboarding-flicker (desktop + mobile): replaced the 12-sample
  50-ms poll-loop with a single auto-retrying `toBeHidden({ timeout:
  700 })` assertion. The poll-loop had a 1-2 ms race window where the
  analytics-pending shell could paint before `useAuth().user` resolved
  `onboardingCompletedAt`. The auto-retrying assertion re-evaluates
  every animation frame and preserves the intent ("user never sees
  the card") while collapsing the window.
- mobile-viewport (chromium-mobile): dropped `nav a[href]` from the
  CTA-touch-target sweep (bottom-nav owns its own WCAG enforcement),
  gated the 44-px floor on a `matchMedia('(min-width: 640px)').matches
  === false` evaluate so the desktop breakpoint never hits the
  assertion, and prefer the `aria-label` over raw `innerText` when
  surfacing failure labels.
2026-05-16 20:53:52 +02:00

150 lines
5.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
* WCAG 2.5.5 hit-target sizing (44×44 CSS px).
*
* 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×44", async ({
page,
}) => {
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;
if (box.width < 44 || 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×44:\n ${failures.join("\n ")}`,
).toEqual([]);
});
});