Files
HealthLog/e2e/cycle-insights-mobile.spec.ts
T
Marc-André Bombeck 29188b2262 fix(insights): let a section heading's trailing caption wrap instead of widening the page
The Insights overview panned sideways at every phone width. SectionHeading
pinned its trailing action slot with shrink-0, which sizes a flex item to its
max-content width. That slot was designed for a compact control, but every
caller passes prose today — the provenance method caption, the device-score
subtitle, the sleep-quality subtitle. A sentence at max-content measures
1229 px in English and 1464 px in German, and since the row sits inside the
AuthShell <main> — whose overflow-y:auto makes overflow-x compute to auto —
that width turned the page into a horizontal scroller. min-w-0 lifts the flex
min-width:auto floor so the caption wraps to the row instead; the parent's
flex-wrap still moves the slot to its own line before anything is squeezed.

The existing width guards could not see this. Both measured
documentElement.scrollWidth, which never budges when <main> absorbs the
overflow into its own scroll area, and the insights case ran against an empty
mocked payload that fits any viewport by construction.

- Add e2e/utils/horizontal-overflow.ts: asserts the document AND the <main>
  scroll container, and names the widest offending element on failure.
- Add e2e/mobile-horizontal-overflow.spec.ts: sweeps /insights, /, /measurements
  and /medications at 360/390/393/402/430 px against populated fixtures, plus a
  canary proving the measurement can see through <main>, and a check that the
  insights fixture really populates the page.
- Rewrite the cycle spec's empty payload as a populated one and sweep every
  phone width; its /insights case moves to the new spec.
- Point mobile-density.spec.ts at the shared assertion — it carried the same
  document-only blind spot.
2026-07-26 17:44:15 +02:00

252 lines
8.0 KiB
TypeScript

import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
expectNoHorizontalOverflow,
PHONE_WIDTHS,
settleForOverflowMeasurement,
} from "./utils/horizontal-overflow";
/**
* Mobile smoke for the cycle vertical (v1.15.10). Asserts:
*
* 1. `/cycle` has no horizontal page scroll at any phone width — covers
* the tab-strip-overflow fix (the four long German labels used to force
* a horizontal scroll at 375 px) and the responsive cycle ring.
* 2. The cycle-calendar day cells clear the WCAG 2.5.5 mobile tap-target
* height floor (44 CSS px) — the `min-h-10` → `min-h-11` bump.
*
* The `/insights` width case that used to live here has moved to
* `mobile-horizontal-overflow.spec.ts`. It asserted against an EMPTY insights
* payload and against `documentElement.scrollWidth`, so it was green through a
* populated page that really did pan sideways inside the `AuthShell` `<main>`
* scroll container. The replacement runs populated fixtures at several phone
* widths and measures the scroll container the app actually uses. The cycle
* fixture below is populated for the same reason.
*
* The cycle feature gate is enabled by rewriting the real `/api/auth/me`
* response with `cycleTrackingEnabled: true` (every other field stays
* authentic), and the `/api/cycle/*` reads are mocked so the wheel, tab strip,
* and calendar render deterministically without seeding cycle data.
*
* Runs only on the `chromium-mobile` project (Pixel 5). The desktop project
* skips this whole describe block.
*/
function ymd(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
/** A 90-day-back → 180-day-forward calendar with a recent labelled cycle run
* so the wheel resolves a current phase + day and the calendar paints cells. */
function buildCalendarDays(): Array<Record<string, unknown>> {
const days: Array<Record<string, unknown>> = [];
const start = new Date();
start.setDate(start.getDate() - 12);
// 5 menstrual, 6 follicular, 1 ovulatory, then luteal through today.
const plan: string[] = [
...Array<string>(5).fill("MENSTRUAL"),
...Array<string>(6).fill("FOLLICULAR"),
"OVULATORY",
"LUTEAL",
"LUTEAL",
];
for (let i = 0; i < plan.length; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
days.push({
date: ymd(d),
phase: plan[i],
isPredictedPeriod: false,
isFertileWindow: plan[i] === "OVULATORY",
isPredictedOvulation: false,
isPeriodLogged: plan[i] === "MENSTRUAL",
flow: plan[i] === "MENSTRUAL" ? "MEDIUM" : null,
hasSymptoms: false,
confidence: 1,
basalBodyTempC: null,
ovulationTest: null,
cervicalMucus: null,
});
}
return days;
}
const CALENDAR_BODY = {
data: {
profile: {
goal: "GENERAL_HEALTH",
rawChartMode: false,
predictionEnabled: true,
cyclesObserved: 1,
},
prediction: null,
days: buildCalendarDays(),
meta: { generatedAt: new Date().toISOString() },
},
error: null,
};
const PROFILE_BODY = {
data: {
goal: "GENERAL_HEALTH",
cycleTrackingEnabled: true,
rawChartMode: false,
predictionEnabled: true,
discreetNotifications: false,
sensitiveCategoryEncryption: false,
typicalCycleLength: 28,
typicalPeriodLength: 5,
lutealPhaseLength: 14,
updatedAt: new Date().toISOString(),
},
error: null,
};
const HISTORY_BODY = {
data: {
cycles: [],
stats: {
avgLengthDays: null,
lengthVariabilityDays: null,
avgPeriodLengthDays: null,
regularity: "LEARNING",
},
},
error: null,
};
const INSIGHTS_BODY = {
data: {
rows: buildInsightRows(),
headline: {
symptom: "HEADACHE",
phase: "LUTEAL",
rate: 0.3,
confidence: "high",
},
symptomPatterns: buildInsightRows(),
},
error: null,
};
/**
* A populated cycle-insights payload. The previous fixture was
* `{ rows: [], headline: null, symptomPatterns: [] }` — an empty page, which
* fits any viewport by construction and proved nothing about the width
* guarantee it was written for. Long labels are deliberate: the tab strip and
* the summary rows are where narrow-width text pressure shows up.
*/
function buildInsightRows(): Array<Record<string, unknown>> {
const symptoms = [
"HEADACHE",
"CRAMPS",
"BLOATING",
"FATIGUE",
"MOOD_SWINGS",
"BREAST_TENDERNESS",
"BACK_PAIN",
"ACNE",
];
return symptoms.map((symptom, i) => ({
symptom,
phase: ["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"][i % 4],
occurrences: 12 - i,
totalDays: 40,
rate: (12 - i) / 40,
confidence: i < 4 ? "high" : "low",
}));
}
test.describe("cycle mobile smoke", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
// Flip the cycle feature gate ON by rewriting the real /api/auth/me
// response — every other field stays authentic so the auth shell behaves
// exactly as in production.
await page.route("**/api/auth/me", async (route) => {
const res = await route.fetch();
const json = await res.json().catch(() => null);
if (!json?.data) return route.fulfill({ response: res });
json.data.cycleTrackingEnabled = true;
return route.fulfill({
response: res,
body: JSON.stringify(json),
headers: { ...res.headers(), "content-type": "application/json" },
});
});
// Deterministic cycle reads.
await page.route(/\/api\/cycle\/calendar(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(CALENDAR_BODY),
}),
);
await page.route("**/api/cycle/profile", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(PROFILE_BODY),
}),
);
await page.route(/\/api\/cycle\/cycles(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(HISTORY_BODY),
}),
);
await page.route("**/api/cycle/insights", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(INSIGHTS_BODY),
}),
);
});
test("/cycle has no horizontal scroll at any phone width and the calendar cells clear the 44 px tap floor", async ({
page,
}) => {
for (const width of PHONE_WIDTHS) {
await page.setViewportSize({ width, height: 851 });
await page.goto("/cycle", { waitUntil: "domcontentloaded" });
// The cycle vertical mounted (the page redirects home if the gate is off).
const wheel = page.locator('[data-slot="cycle-wheel-tile"]');
await expect(wheel).toBeVisible();
await settleForOverflowMeasurement(page);
await expectNoHorizontalOverflow(page, `/cycle @${width}px`);
}
// Back to the Pixel-5 width for the tap-target measurement.
await page.setViewportSize({ width: 393, height: 851 });
await page.goto("/cycle", { waitUntil: "domcontentloaded" });
await expect(page.locator('[data-slot="cycle-wheel-tile"]')).toBeVisible();
await page.waitForLoadState("networkidle").catch(() => {});
// 2) Calendar day cells clear the 44 px tap-target height floor.
const cells = await page.locator('[role="gridcell"]').all();
const failures: string[] = [];
for (const cell of cells) {
if (!(await cell.isVisible().catch(() => false))) continue;
const box = await cell.boundingBox();
if (!box) continue;
if (box.height < 44) {
const label = (await cell.getAttribute("aria-label")) ?? "(cell)";
failures.push(`${label.slice(0, 30)} → ${box.height.toFixed(1)}px`);
}
}
expect(
failures,
`Calendar cells below 44 px tall:\n ${failures.join("\n ")}`,
).toEqual([]);
});
});