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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 10:36:57 +02:00
1 parent 29e2b44310
commit 714aa0f2c0
2 files changed
+221 -5

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
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([]);
});
}
}
});
+66 -5
View File
@@ -4,6 +4,8 @@ import {
DASHBOARD_WIDGET_CATALOGUE_IDS,
DEFAULT_DASHBOARD_LAYOUT,
} from "@/lib/dashboard-layout";
import type { DailyBriefing } from "@/lib/ai/schema";
import type { DashboardScoreRing } from "@/lib/dashboard/score-rings";
import type { DataSummary } from "@/lib/analytics/trends";
import type {
DashboardSnapshot,
@@ -172,6 +174,54 @@ function buildLayoutCatalogue(): DashboardLayoutCatalogueEntry[] {
}));
}
/**
* Long-headline briefing fixture for the hero density guard: every
* signal pairs a wrapping German headline with a wide delta string —
* the exact shape that squeezed the spotlight rows on phone widths.
*/
export const LONG_HEADLINE_BRIEFING: DailyBriefing = {
paragraph:
"Dein systolischer Blutdruck liegt heute deutlich unter deinem Monatsmittel, während die Schlafdauer stabil bleibt.",
signalsOfDay: [
{
sourceMetric: "bp",
tone: "watch",
headline:
"Systolischer Blutdruck deutlich unter deinem Monatsmittel gemessen",
nudge: "Miss heute Abend erneut, um den Wert einzuordnen.",
delta: "−12 mmHg vs. 30-Tage-Mittel",
},
{
sourceMetric: "sleep",
tone: "info",
headline:
"Schlafdauer in den letzten sieben Nächten etwa eine Stunde kürzer als üblich",
nudge: "Plane heute eine frühere Nachtruhe ein.",
delta: "−58 min",
},
{
sourceMetric: "compliance",
tone: "good",
headline: "Alle geplanten Dosen der Woche bisher vollständig eingenommen",
nudge: "Weiter so — die Abenddosis steht um 20:00 an.",
delta: "7/7 Tage",
},
],
keyFindings: [],
};
/** Three-ring hero row fixture (max the hero renders beside the score). */
export const MOCK_SCORE_RINGS: DashboardScoreRing[] = [
{ id: "READINESS", score: 82, band: "green" },
{ id: "RECOVERY_SCORE", score: 74, band: "yellow" },
{
id: "MED_COMPLIANCE",
score: 33,
band: "yellow",
doses: { taken: 1, scheduled: 3 },
},
];
export interface MockSnapshotOptions {
/**
* Tile summaries keyed by `MeasurementType`. Defaults to the
@@ -181,6 +231,12 @@ export interface MockSnapshotOptions {
summaries?: Record<string, DataSummary>;
/** Artificial latency in ms before the route fulfils (race specs). */
delayMs?: number;
/** Render the dashboard hero band (`layout.heroVisible` opt-in). */
heroVisible?: boolean;
/** Fresh (`ready`, non-stale) briefing for the hero spotlight rows. */
briefing?: DailyBriefing;
/** Selected hero score rings (renders beside the health-score ring). */
scoreRings?: DashboardScoreRing[];
}
/** Assemble a fully-typed snapshot body from the requested tile data. */
@@ -207,7 +263,9 @@ export function buildMockSnapshot(
onboardingTourCompleted: true,
greetingHour: 9,
},
layout: DEFAULT_DASHBOARD_LAYOUT,
layout: options.heroVisible
? { ...DEFAULT_DASHBOARD_LAYOUT, heroVisible: true }
: DEFAULT_DASHBOARD_LAYOUT,
layoutCatalogue: buildLayoutCatalogue(),
metricStates: {},
targetBands: mockTargetBands(),
@@ -226,10 +284,13 @@ export function buildMockSnapshot(
nextDueOverdue: false,
nextDueMedicationName: null,
},
healthScore: null,
briefing: null,
briefingState: "preparing",
briefingUpdatedAt: null,
healthScore: options.briefing
? { score: 84, band: "green", delta: -2, restMode: null }
: null,
scoreRings: options.scoreRings,
briefing: options.briefing ?? null,
briefingState: options.briefing ? "ready" : "preparing",
briefingUpdatedAt: options.briefing ? now : null,
briefingStale: false,
generatedAt: now,
};