mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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.
217 lines
8.1 KiB
TypeScript
217 lines
8.1 KiB
TypeScript
import { expect, test } from "./setup/test";
|
||
|
||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||
import {
|
||
LONG_HEADLINE_BRIEFING,
|
||
MOCK_SCORE_RINGS,
|
||
mockDashboardSnapshot,
|
||
POPULATED_SUMMARIES,
|
||
} from "./utils/mock-dashboard-snapshot";
|
||
import { mockMoodInsights } from "./utils/mock-mood-insights";
|
||
import { expectNoHorizontalOverflow } from "./utils/horizontal-overflow";
|
||
|
||
/**
|
||
* Phone-width density guard (390×844 iPhone class, 360×780 small
|
||
* Android class).
|
||
*
|
||
* Two invariants, per the original overflow report (long headline text
|
||
* squeezed into a one-word-per-line column beside a wide delta, the
|
||
* delta spilling past the tile edge at 360 px):
|
||
*
|
||
* 1. No horizontal page overflow — neither the document nor the `AuthShell`
|
||
* `<main>` scroll container exceeds the viewport width (1 px sub-pixel
|
||
* tolerance). The `<main>` half is load-bearing: this guard measured only
|
||
* the document until a report of sideways scroll on a populated Insights
|
||
* page showed the document never widens, because `<main>` absorbs the
|
||
* overflow into its own scrollable area.
|
||
* 2. Nothing escapes its tile — for every card / Today hero / list
|
||
* row, no descendant's visible box crosses the tile's left or
|
||
* right edge (clip-aware: content genuinely hidden by an inner
|
||
* `overflow-hidden` wrapper below the tile is fine; content cut
|
||
* off at the tile's own edge is the bug).
|
||
*
|
||
* The mocks pin the worst case: the Today hero (its score face + a
|
||
* worth-a-look rail) plus a fresh briefing whose rows pair wrapping
|
||
* German headlines with wide delta strings.
|
||
*
|
||
* Desktop project only — the assertions are viewport-driven via
|
||
* `setViewportSize` (the `ipad-viewport.spec.ts` pattern).
|
||
*/
|
||
const VIEWPORTS = [
|
||
{ width: 390, height: 844 },
|
||
{ width: 360, height: 780 },
|
||
] as const;
|
||
|
||
const ROUTES = ["/", "/insights", "/insights/mood"] as const;
|
||
|
||
interface TileEscape {
|
||
tile: string;
|
||
child: string;
|
||
overRight: number;
|
||
overLeft: number;
|
||
text: string;
|
||
}
|
||
|
||
/** Serialised into the page: measure children escaping their tile. */
|
||
function collectTileEscapes(): TileEscape[] {
|
||
const escapes: TileEscape[] = [];
|
||
const tiles = document.querySelectorAll(
|
||
'[data-slot="card"], [data-slot="today-hero"], [data-slot="list-row"]',
|
||
);
|
||
const TOLERANCE = 1;
|
||
for (const tile of tiles) {
|
||
const tileRect = tile.getBoundingClientRect();
|
||
if (tileRect.width === 0) continue;
|
||
for (const child of tile.querySelectorAll("*")) {
|
||
const rect = child.getBoundingClientRect();
|
||
if (rect.width === 0 && rect.height === 0) continue;
|
||
if (getComputedStyle(child).position === "fixed") continue;
|
||
// Clamp by clipping ancestors BELOW the tile only — the tile's
|
||
// own overflow-hidden cutting content off is exactly the bug.
|
||
let left = rect.left;
|
||
let right = rect.right;
|
||
let ancestor = child.parentElement;
|
||
while (ancestor && ancestor !== tile) {
|
||
if (getComputedStyle(ancestor).overflowX !== "visible") {
|
||
const ancestorRect = ancestor.getBoundingClientRect();
|
||
left = Math.max(left, ancestorRect.left);
|
||
right = Math.min(right, ancestorRect.right);
|
||
}
|
||
ancestor = ancestor.parentElement;
|
||
}
|
||
if (right <= left) continue;
|
||
const overRight = right - tileRect.right;
|
||
const overLeft = tileRect.left - left;
|
||
if (overRight > TOLERANCE || overLeft > TOLERANCE) {
|
||
escapes.push({
|
||
tile: tile.getAttribute("data-slot") ?? tile.tagName,
|
||
child:
|
||
child.tagName.toLowerCase() +
|
||
(child.getAttribute("data-slot")
|
||
? `[${child.getAttribute("data-slot")}]`
|
||
: ""),
|
||
overRight: Math.round(overRight * 10) / 10,
|
||
overLeft: Math.round(overLeft * 10) / 10,
|
||
text: (child.textContent ?? "").trim().slice(0, 60),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return escapes;
|
||
}
|
||
|
||
test.describe("phone-width density guard", () => {
|
||
test.use({ storageState: STORAGE_STATE_PATH });
|
||
|
||
test.beforeEach(({}, testInfo) => {
|
||
test.skip(
|
||
testInfo.project.name !== "chromium-desktop",
|
||
"viewport-driven spec; desktop project only",
|
||
);
|
||
});
|
||
|
||
for (const viewport of VIEWPORTS) {
|
||
for (const route of ROUTES) {
|
||
test(`no overflow and no tile escape on ${route} at ${viewport.width}x${viewport.height}`, async ({
|
||
page,
|
||
}) => {
|
||
await page.setViewportSize(viewport);
|
||
await mockDashboardSnapshot(page, {
|
||
summaries: POPULATED_SUMMARIES,
|
||
briefing: LONG_HEADLINE_BRIEFING,
|
||
scoreRings: MOCK_SCORE_RINGS,
|
||
});
|
||
// The Today hero reads the unified daily digest from
|
||
// `/api/daily/digest`. Mock it with a deterministic digest (a
|
||
// score + one worth-a-look item) so the hero paints without
|
||
// reaching the real route — mirror of the dashboard.spec mock.
|
||
await page.route(/\/api\/daily\/digest(\?|$)/, (route) =>
|
||
route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
data: {
|
||
generatedAt: new Date().toISOString(),
|
||
phase: "final",
|
||
sleepPending: false,
|
||
score: { value: 82, band: "green", delta: 2 },
|
||
topSignal: null,
|
||
briefingLead: "Your week is trending steady.",
|
||
line: "Your week is trending steady.",
|
||
worthALook: [
|
||
{
|
||
kind: "sync_issue",
|
||
title: "Sync needs attention",
|
||
body: "Withings isn't syncing.",
|
||
status: "warning",
|
||
actions: [
|
||
{
|
||
labelKey: "daily.action.reconnect",
|
||
intent: "sync.reconnect",
|
||
href: "/settings/integrations",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
error: null,
|
||
}),
|
||
}),
|
||
);
|
||
if (route === "/insights/mood") {
|
||
await mockMoodInsights(page);
|
||
}
|
||
await page.goto(route, { waitUntil: "domcontentloaded" });
|
||
await page.waitForLoadState("networkidle");
|
||
|
||
if (route === "/") {
|
||
// The guard must not pass vacuously — the Today hero and its
|
||
// score face have to actually render.
|
||
await expect(page.locator('[data-slot="today-hero"]')).toBeVisible();
|
||
await expect(
|
||
page.locator('[data-slot="today-hero-score"]'),
|
||
).toBeVisible();
|
||
}
|
||
|
||
if (route === "/insights/mood") {
|
||
// Non-vacuous: the five populated correlation cards and the
|
||
// discovered-relations list — the audit's only user-facing
|
||
// tile-escape cluster — must actually render before the guard
|
||
// sweeps them.
|
||
await expect(
|
||
page.locator('[data-slot="mood-correlation-card"]'),
|
||
).toHaveCount(5);
|
||
await expect(
|
||
page.locator('[data-slot="mood-discovered-relations"]'),
|
||
).toBeVisible();
|
||
}
|
||
|
||
// The document/body pair alone cannot see the app's sideways scroll:
|
||
// `AuthShell` scrolls inside `<main id="main-content">`, whose
|
||
// `overflow-y:auto` makes `overflow-x` compute to `auto`, so an
|
||
// over-wide child grows `main.scrollWidth` while the document stays
|
||
// exactly viewport-wide. The shared helper asserts both areas and
|
||
// names the offending element.
|
||
await expectNoHorizontalOverflow(
|
||
page,
|
||
`${route} @${viewport.width}x${viewport.height}`,
|
||
);
|
||
const bodyScrollWidth = await page.evaluate(
|
||
() => document.body.scrollWidth,
|
||
);
|
||
const innerWidth = await page.evaluate(() => window.innerWidth);
|
||
expect(
|
||
bodyScrollWidth,
|
||
`body scrollWidth=${bodyScrollWidth}`,
|
||
).toBeLessThanOrEqual(innerWidth + 1);
|
||
|
||
const escapes = await page.evaluate(collectTileEscapes);
|
||
expect(
|
||
escapes,
|
||
`tile escapes: ${JSON.stringify(escapes, null, 2)}`,
|
||
).toEqual([]);
|
||
});
|
||
}
|
||
}
|
||
});
|