Files
HealthLog/e2e/mobile-viewport.spec.ts
T
Marc-André BombeckandClaude Opus 4.7 a106547e20 test(e2e): doctor-report PDF + mobile-viewport smoke
doctor-report.spec.ts mocks /api/doctor-report with a
shape-correct payload, clicks the "Doctor report" button on
/settings/advanced, captures the resulting download via Playwright's
`waitForEvent('download')`, and asserts the file is >1KB and starts
with the `%PDF` magic.

mobile-viewport.spec.ts is a Pixel 5 only smoke that verifies the
dashboard has no horizontal scroll, the fixed bottom-nav doesn't
clobber main content, and every interactive CTA visible above the
fold meets WCAG 2.5.5 (≥44×44 CSS px). Skips on the desktop project
via testInfo.project.name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 16:49:09 +02:00

128 lines
4.5 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
const buttons = await page
.locator("main button, main a[href], nav a[href]")
.filter({ has: page.locator(":visible") })
.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) {
const text = (await btn.innerText().catch(() => "")) || "(no text)";
failures.push(
`${text.slice(0, 40)} → ${box.width.toFixed(1)}×${box.height.toFixed(1)}`,
);
}
}
expect(
failures,
`Touch-targets below 44×44:\n ${failures.join("\n ")}`,
).toEqual([]);
});
});