Files
HealthLog/e2e/mobile-density.spec.ts
T
Marc-André Bombeck 714aa0f2c0 test(e2e): guard phone widths against overflow and tile escapes
New density guard at 390x844 and 360x780 on the dashboard and
/insights: page scrollWidth never exceeds the viewport, and no
descendant's visible box crosses its card / hero / list-row edge
(clip-aware below the tile; content cut off at the tile's own edge
counts as an escape). The snapshot mock gains hero fixtures — layout
heroVisible, three score rings, and a briefing whose signal rows pair
long wrapping headlines with wide delta strings — so the guard
exercises the exact shape that regressed, and asserts the spotlight
rows actually render rather than passing vacuously.
2026-07-07 10:36:57 +02:00

156 lines
5.4 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";
import {
LONG_HEADLINE_BRIEFING,
MOCK_SCORE_RINGS,
mockDashboardSnapshot,
POPULATED_SUMMARIES,
} from "./utils/mock-dashboard-snapshot";
/**
* Phone-width density guard (390×844 iPhone class, 360×780 small
* Android class).
*
* Two invariants, per the report against the dashboard hero (a long
* briefing-signal headline 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 — `scrollWidth` never exceeds the
* viewport width (1 px sub-pixel tolerance).
* 2. Nothing escapes its tile — for every card / hero band / 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 snapshot mock pins the worst case: hero band on, three score
* rings, and a fresh briefing whose signal 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"] 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="dashboard-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,
heroVisible: true,
briefing: LONG_HEADLINE_BRIEFING,
scoreRings: MOCK_SCORE_RINGS,
});
await page.goto(route, { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
if (route === "/") {
// The guard must not pass vacuously — the hero band and its
// long-headline spotlight rows have to actually render.
await expect(
page.locator('[data-slot="dashboard-hero"]'),
).toBeVisible();
await expect(
page.locator('[data-slot="dashboard-briefing-spotlight-row"]'),
).toHaveCount(3);
}
const dims = await page.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
bodyScrollWidth: document.body.scrollWidth,
innerWidth: window.innerWidth,
}));
expect(
dims.scrollWidth,
`page scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
).toBeLessThanOrEqual(dims.innerWidth + 1);
expect(
dims.bodyScrollWidth,
`body scrollWidth=${dims.bodyScrollWidth}`,
).toBeLessThanOrEqual(dims.innerWidth + 1);
const escapes = await page.evaluate(collectTileEscapes);
expect(
escapes,
`tile escapes: ${JSON.stringify(escapes, null, 2)}`,
).toEqual([]);
});
}
}
});