mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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.
This commit is contained in:
1 parent
78644ab77e
commit
29188b2262
6 files changed
+782
-47
No files matched your search
@@ -1,20 +1,29 @@
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import {
|
||||
expectNoHorizontalOverflow,
|
||||
PHONE_WIDTHS,
|
||||
settleForOverflowMeasurement,
|
||||
} from "./utils/horizontal-overflow";
|
||||
|
||||
/**
|
||||
* Pixel-5 mobile smoke for the Insights overview + the cycle vertical
|
||||
* (v1.15.10). Mirrors `mobile-viewport.spec.ts`. Asserts:
|
||||
* Mobile smoke for the cycle vertical (v1.15.10). Asserts:
|
||||
*
|
||||
* 1. `/insights` has no horizontal page scroll on the Pixel-5 width — the
|
||||
* core guarantee behind the cycle-ring-width fix (the 120 px ring tile
|
||||
* used to overflow narrow grid cells).
|
||||
* 2. `/cycle` has no horizontal page scroll on the Pixel-5 width — covers
|
||||
* 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.
|
||||
* 3. The cycle-calendar day cells clear the WCAG 2.5.5 mobile tap-target
|
||||
* 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,
|
||||
@@ -108,22 +117,48 @@ const HISTORY_BODY = {
|
||||
};
|
||||
|
||||
const INSIGHTS_BODY = {
|
||||
data: { rows: [], headline: null, symptomPatterns: [] },
|
||||
data: {
|
||||
rows: buildInsightRows(),
|
||||
headline: {
|
||||
symptom: "HEADACHE",
|
||||
phase: "LUTEAL",
|
||||
rate: 0.3,
|
||||
confidence: "high",
|
||||
},
|
||||
symptomPatterns: buildInsightRows(),
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
|
||||
async function assertNoHorizontalScroll(page: import("@playwright/test").Page) {
|
||||
const dims = await page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
innerWidth: window.innerWidth,
|
||||
/**
|
||||
* 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",
|
||||
}));
|
||||
expect(
|
||||
dims.scrollWidth,
|
||||
`scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
|
||||
).toBeLessThanOrEqual(dims.innerWidth + 1);
|
||||
}
|
||||
|
||||
test.describe("cycle + insights mobile smoke", () => {
|
||||
test.describe("cycle mobile smoke", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
@@ -175,24 +210,26 @@ test.describe("cycle + insights mobile smoke", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("/insights has no horizontal scroll on Pixel-5", async ({ page }) => {
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle");
|
||||
await assertNoHorizontalScroll(page);
|
||||
});
|
||||
|
||||
test("/cycle has no horizontal scroll and the calendar cells clear the 44 px tap floor", async ({
|
||||
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 page.waitForLoadState("networkidle");
|
||||
|
||||
// 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();
|
||||
|
||||
// 1) No horizontal page scroll at the Pixel-5 width.
|
||||
await assertNoHorizontalScroll(page);
|
||||
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();
|
||||
|
||||
+24
-14
@@ -8,6 +8,7 @@ import {
|
||||
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
|
||||
@@ -17,8 +18,12 @@ import { mockMoodInsights } from "./utils/mock-mood-insights";
|
||||
* 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).
|
||||
* 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
|
||||
@@ -181,19 +186,24 @@ test.describe("phone-width density guard", () => {
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
const dims = await page.evaluate(() => ({
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
bodyScrollWidth: document.body.scrollWidth,
|
||||
innerWidth: window.innerWidth,
|
||||
}));
|
||||
// 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(
|
||||
dims.scrollWidth,
|
||||
`page scrollWidth=${dims.scrollWidth}, innerWidth=${dims.innerWidth}`,
|
||||
).toBeLessThanOrEqual(dims.innerWidth + 1);
|
||||
expect(
|
||||
dims.bodyScrollWidth,
|
||||
`body scrollWidth=${dims.bodyScrollWidth}`,
|
||||
).toBeLessThanOrEqual(dims.innerWidth + 1);
|
||||
bodyScrollWidth,
|
||||
`body scrollWidth=${bodyScrollWidth}`,
|
||||
).toBeLessThanOrEqual(innerWidth + 1);
|
||||
|
||||
const escapes = await page.evaluate(collectTileEscapes);
|
||||
expect(
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { expect, test } from "./setup/test";
|
||||
|
||||
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
|
||||
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
|
||||
import {
|
||||
expectNoHorizontalOverflow,
|
||||
measureHorizontalOverflow,
|
||||
PHONE_WIDTHS,
|
||||
settleForOverflowMeasurement,
|
||||
} from "./utils/horizontal-overflow";
|
||||
|
||||
/**
|
||||
* The content-heavy routes must not scroll sideways at any phone width.
|
||||
*
|
||||
* This replaces a per-surface habit of asserting
|
||||
* `documentElement.scrollWidth <= innerWidth` on whatever happened to render.
|
||||
* That assertion had two holes, and a real report walked through both:
|
||||
*
|
||||
* 1. It measured the DOCUMENT. `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`, the
|
||||
* user pans the page sideways, and the document never widens by a pixel.
|
||||
* 2. It ran against an EMPTY page. A surface with no content fits any
|
||||
* viewport, so the guard proved nothing about the surface people use.
|
||||
*
|
||||
* `expectNoHorizontalOverflow` closes (1) by asserting both scroll areas and
|
||||
* naming the widest offending element on failure. The fixtures below close (2)
|
||||
* by giving each route a body before it is measured.
|
||||
*
|
||||
* Widths cover real iPhone (390 / 393 / 402 / 430) and common Android (360)
|
||||
* CSS widths — a single width hides anything whose overflow is width-dependent.
|
||||
* Runs on `chromium-mobile` only; the desktop project skips the whole file.
|
||||
*/
|
||||
|
||||
interface RouteCase {
|
||||
path: string;
|
||||
/** Something that must be on-screen before measuring — proves it is populated. */
|
||||
ready: string;
|
||||
setup?: (page: import("@playwright/test").Page) => Promise<void>;
|
||||
}
|
||||
|
||||
const ROUTE_CASES: RouteCase[] = [
|
||||
{
|
||||
path: "/insights",
|
||||
// The "signals of the day" section is the one whose heading carries the
|
||||
// long provenance sentence, so it is also the readiness signal.
|
||||
ready: '[data-slot="coincident-deviation-section"]',
|
||||
setup: mockPopulatedInsights,
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
ready: "main#main-content",
|
||||
setup: (page) => mockDashboardSnapshot(page),
|
||||
},
|
||||
{ path: "/measurements", ready: "main#main-content" },
|
||||
{ path: "/medications", ready: "main#main-content" },
|
||||
];
|
||||
|
||||
test.describe("content-heavy routes have no horizontal page scroll at phone widths", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.beforeEach(async ({}, testInfo) => {
|
||||
test.skip(testInfo.project.name !== "chromium-mobile", "mobile-only spec");
|
||||
});
|
||||
|
||||
for (const routeCase of ROUTE_CASES) {
|
||||
test(`${routeCase.path} does not scroll sideways on any phone width`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeCase.setup?.(page);
|
||||
|
||||
for (const width of PHONE_WIDTHS) {
|
||||
await page.setViewportSize({ width, height: 851 });
|
||||
await page.goto(routeCase.path, { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator(routeCase.ready).first()).toBeVisible();
|
||||
await settleForOverflowMeasurement(page);
|
||||
await expectNoHorizontalOverflow(page, `${routeCase.path} @${width}px`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The fixture itself has to stay honest. If `mockPopulatedInsights` ever
|
||||
* stops populating the overview — a renamed route, a changed envelope — the
|
||||
* width assertions above would keep passing against an empty page and the
|
||||
* guard would quietly go back to proving nothing. Assert the page is
|
||||
* actually carrying content, so fixture rot fails loudly instead.
|
||||
*/
|
||||
test("the /insights fixture really populates the overview", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockPopulatedInsights(page);
|
||||
await page.setViewportSize({ width: 390, height: 851 });
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const signals = page.locator('[data-slot="coincident-deviation-section"]');
|
||||
await expect(signals).toBeVisible();
|
||||
|
||||
// The prose in the heading's trailing action slot — the shape that widened
|
||||
// the page. It must be present AND long enough to be able to.
|
||||
const method = signals.locator('[data-slot="provenance-explainer-method"]');
|
||||
await expect(method).toBeVisible();
|
||||
const text = (await method.textContent()) ?? "";
|
||||
expect(
|
||||
text.length,
|
||||
`provenance caption too short to exercise the overflow class: "${text}"`,
|
||||
).toBeGreaterThan(60);
|
||||
|
||||
// And the surface as a whole is not the empty state.
|
||||
await expect(
|
||||
page.locator('[data-slot="section-heading"]').first(),
|
||||
).toBeVisible();
|
||||
const headingCount = await page
|
||||
.locator('[data-slot="section-heading"]')
|
||||
.count();
|
||||
expect(headingCount, "overview rendered no sections").toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* A long trailing caption must WRAP inside the row, not extend it. Asserting
|
||||
* the wrap directly (rather than only the page-level width) pins the fix at
|
||||
* the element that broke: a `shrink-0` action slot sized itself to the
|
||||
* caption's max-content width, ~1.2-1.5k px depending on locale.
|
||||
*/
|
||||
test("a long heading action caption wraps instead of widening the row", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockPopulatedInsights(page);
|
||||
await page.setViewportSize({ width: 390, height: 851 });
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const heading = page
|
||||
.locator(
|
||||
'[data-slot="coincident-deviation-section"] [data-slot="section-heading"]',
|
||||
)
|
||||
.first();
|
||||
await expect(heading).toBeVisible();
|
||||
|
||||
const box = await heading.boundingBox();
|
||||
const method = heading.locator('[data-slot="provenance-explainer-method"]');
|
||||
const methodBox = await method.boundingBox();
|
||||
expect(box, "heading has no box").not.toBeNull();
|
||||
expect(methodBox, "provenance caption has no box").not.toBeNull();
|
||||
|
||||
expect(
|
||||
Math.round(methodBox!.width),
|
||||
`caption width ${methodBox!.width} exceeds its heading row ${box!.width} — the action slot is sizing to max-content instead of wrapping`,
|
||||
).toBeLessThanOrEqual(Math.round(box!.width) + 1);
|
||||
expect(
|
||||
Math.round(box!.width),
|
||||
"heading row is wider than the viewport",
|
||||
).toBeLessThanOrEqual(390);
|
||||
});
|
||||
|
||||
/**
|
||||
* The helper must be able to SEE overflow through the `<main>` scroll
|
||||
* container — the exact blind spot that let the previous guard pass. Inject
|
||||
* a deliberately over-wide, un-contained block and assert the measurement
|
||||
* reports it. Without this, a helper that silently measured the wrong
|
||||
* element would make every assertion above vacuous.
|
||||
*/
|
||||
test("the overflow measurement detects an over-wide block inside <main>", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockPopulatedInsights(page);
|
||||
await page.setViewportSize({ width: 390, height: 851 });
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator("main#main-content")).toBeVisible();
|
||||
|
||||
const clean = await measureHorizontalOverflow(page);
|
||||
expect(clean.mainScrollWidth).toBeLessThanOrEqual(
|
||||
(clean.mainClientWidth ?? 0) + 1,
|
||||
);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const probe = document.createElement("div");
|
||||
probe.id = "overflow-canary";
|
||||
probe.style.width = "1500px";
|
||||
probe.style.height = "10px";
|
||||
document.querySelector("main#main-content")!.appendChild(probe);
|
||||
});
|
||||
|
||||
const dirty = await measureHorizontalOverflow(page);
|
||||
expect(
|
||||
dirty.mainScrollWidth,
|
||||
"an intentionally 1500px-wide block did not widen main's scroll area — the helper is measuring the wrong element",
|
||||
).toBeGreaterThan((dirty.mainClientWidth ?? 0) + 1);
|
||||
expect(
|
||||
dirty.offenders.some((o) => o.selector.includes("overflow-canary")),
|
||||
`the canary was not reported as an offender; got: ${dirty.offenders
|
||||
.map((o) => o.selector)
|
||||
.join(" | ")}`,
|
||||
).toBe(true);
|
||||
// The document stayed exactly as wide as the viewport while `<main>` grew
|
||||
// by 1500px — this is the blind spot, asserted rather than described.
|
||||
expect(
|
||||
dirty.docScrollWidth,
|
||||
"document width changed, so the document-only assertion would have caught this after all",
|
||||
).toBeLessThanOrEqual(dirty.innerWidth + 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Shared horizontal-overflow guard for phone widths.
|
||||
*
|
||||
* WHY THIS MEASURES `<main>` AND NOT JUST THE DOCUMENT
|
||||
* ----------------------------------------------------
|
||||
* The obvious assertion — `documentElement.scrollWidth <= innerWidth` — cannot
|
||||
* observe the app's real sideways scroll. `AuthShell` puts every route inside
|
||||
*
|
||||
* <main id="main-content" class="… overflow-y-auto …">
|
||||
*
|
||||
* and CSS says that when one of `overflow-x` / `overflow-y` is not `visible`,
|
||||
* the other computes to `auto`. So `<main>` is itself a horizontal scroll
|
||||
* container: an over-wide child grows `main.scrollWidth`, the user pans the
|
||||
* page sideways, and `documentElement.scrollWidth` never budges because
|
||||
* `<main>` absorbed the overflow. A document-only assertion is green through
|
||||
* exactly the defect a person reports as "I can scroll sideways off the page".
|
||||
*
|
||||
* Both measurements are therefore asserted. On failure the helper names the
|
||||
* widest element that is actually extending the scroll area (skipping anything
|
||||
* an ancestor already scrolls or clips — a strip that is *meant* to pan inside
|
||||
* its own `overflow-x-auto` container is not a defect), with its measured
|
||||
* width and the viewport width, so a future failure is actionable without a
|
||||
* screenshot.
|
||||
*/
|
||||
|
||||
/** Real iPhone + common Android CSS widths. */
|
||||
export const PHONE_WIDTHS = [360, 390, 393, 402, 430] as const;
|
||||
|
||||
export interface OverflowOffender {
|
||||
selector: string;
|
||||
rectWidth: number;
|
||||
rectRight: number;
|
||||
scrollWidth: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface OverflowReport {
|
||||
innerWidth: number;
|
||||
docScrollWidth: number;
|
||||
/** null when the route renders outside `AuthShell` (no `<main id>`). */
|
||||
mainScrollWidth: number | null;
|
||||
mainClientWidth: number | null;
|
||||
offenders: OverflowOffender[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure horizontal overflow of the document AND of the `AuthShell` scroll
|
||||
* container, plus the elements responsible.
|
||||
*/
|
||||
export async function measureHorizontalOverflow(
|
||||
page: Page,
|
||||
): Promise<OverflowReport> {
|
||||
return page.evaluate(() => {
|
||||
const vw = window.innerWidth;
|
||||
const de = document.documentElement;
|
||||
const main = document.querySelector<HTMLElement>("main#main-content");
|
||||
|
||||
const report = {
|
||||
innerWidth: vw,
|
||||
docScrollWidth: de.scrollWidth,
|
||||
mainScrollWidth: main ? main.scrollWidth : null,
|
||||
mainClientWidth: main ? main.clientWidth : null,
|
||||
offenders: [] as Array<{
|
||||
selector: string;
|
||||
rectWidth: number;
|
||||
rectRight: number;
|
||||
scrollWidth: number;
|
||||
path: string;
|
||||
}>,
|
||||
};
|
||||
|
||||
const overflows =
|
||||
de.scrollWidth > vw + 1 ||
|
||||
(main !== null && main.scrollWidth > main.clientWidth + 1);
|
||||
if (!overflows) return report;
|
||||
|
||||
const describe = (el: Element): string => {
|
||||
const parts = [el.tagName.toLowerCase()];
|
||||
if (el.id) parts.push(`#${el.id}`);
|
||||
const slot = el.getAttribute("data-slot");
|
||||
if (slot) parts.push(`[data-slot="${slot}"]`);
|
||||
const cls = (el.getAttribute("class") ?? "").trim();
|
||||
if (cls) parts.push(`.${cls.split(/\s+/).slice(0, 10).join(".")}`);
|
||||
return parts.join("").slice(0, 180);
|
||||
};
|
||||
|
||||
const root = main ?? document.body;
|
||||
const limit = main
|
||||
? main.getBoundingClientRect().left + main.clientWidth
|
||||
: vw;
|
||||
const scrollLeft = main ? main.scrollLeft : window.scrollX;
|
||||
|
||||
// An element whose ancestor (below `root`) scrolls or clips is contained by
|
||||
// design — the UI standards' "wide content scrolls in its own container".
|
||||
const absorbedByAncestor = (el: Element): boolean => {
|
||||
let n = el.parentElement;
|
||||
while (n && n !== root) {
|
||||
const ox = getComputedStyle(n).overflowX;
|
||||
if (
|
||||
ox === "auto" ||
|
||||
ox === "scroll" ||
|
||||
ox === "hidden" ||
|
||||
ox === "clip"
|
||||
)
|
||||
return true;
|
||||
n = n.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
for (const el of Array.from(root.querySelectorAll("*"))) {
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.width === 0 && r.height === 0) continue;
|
||||
// Fixed/sticky chrome is positioned against the viewport, not the
|
||||
// scroll area, so it cannot widen it.
|
||||
const position = getComputedStyle(el).position;
|
||||
if (position === "fixed" || position === "sticky") continue;
|
||||
const right = r.right + scrollLeft;
|
||||
if (right <= limit + 1) continue;
|
||||
if (absorbedByAncestor(el)) continue;
|
||||
|
||||
const chain: string[] = [];
|
||||
let n: Element | null = el;
|
||||
while (n && n !== de) {
|
||||
chain.unshift(describe(n).slice(0, 70));
|
||||
n = n.parentElement;
|
||||
}
|
||||
report.offenders.push({
|
||||
selector: describe(el),
|
||||
rectWidth: Math.round(r.width),
|
||||
rectRight: Math.round(right),
|
||||
scrollWidth: el.scrollWidth,
|
||||
path: chain.slice(-5).join(" > "),
|
||||
});
|
||||
}
|
||||
|
||||
report.offenders.sort((a, b) => b.rectWidth - a.rectWidth);
|
||||
report.offenders = report.offenders.slice(0, 5);
|
||||
return report;
|
||||
});
|
||||
}
|
||||
|
||||
function formatReport(label: string, report: OverflowReport): string {
|
||||
const lines = [
|
||||
`${label}: horizontal overflow at viewport ${report.innerWidth}px`,
|
||||
` document.scrollWidth = ${report.docScrollWidth} (viewport ${report.innerWidth})`,
|
||||
` main.scrollWidth = ${report.mainScrollWidth} (client ${report.mainClientWidth})`,
|
||||
];
|
||||
if (report.offenders.length === 0) {
|
||||
lines.push(
|
||||
" no un-contained offender found — the overflow may come from a scroll container's own content",
|
||||
);
|
||||
}
|
||||
for (const o of report.offenders) {
|
||||
lines.push(
|
||||
` • width ${o.rectWidth}px, right edge ${o.rectRight}px — ${o.selector}`,
|
||||
);
|
||||
lines.push(` path: ${o.path}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the page does not scroll sideways — neither the document nor the
|
||||
* `AuthShell` `<main>` scroll container.
|
||||
*/
|
||||
export async function expectNoHorizontalOverflow(
|
||||
page: Page,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
const report = await measureHorizontalOverflow(page);
|
||||
const message = formatReport(label, report);
|
||||
|
||||
expect(report.docScrollWidth, message).toBeLessThanOrEqual(
|
||||
report.innerWidth + 1,
|
||||
);
|
||||
if (report.mainScrollWidth !== null && report.mainClientWidth !== null) {
|
||||
expect(report.mainScrollWidth, message).toBeLessThanOrEqual(
|
||||
report.mainClientWidth + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle a route before measuring: let the network go idle, pan the whole
|
||||
* scroll container so every `next/dynamic` below-the-fold block mounts, then
|
||||
* return to the top. A block that only mounts on scroll can only overflow once
|
||||
* it has mounted.
|
||||
*/
|
||||
export async function settleForOverflowMeasurement(page: Page): Promise<void> {
|
||||
await page.waitForLoadState("networkidle").catch(() => {});
|
||||
await page.evaluate(async () => {
|
||||
const main = document.querySelector<HTMLElement>("main#main-content");
|
||||
const el: HTMLElement = main ?? document.documentElement;
|
||||
const step = el.clientHeight || 800;
|
||||
for (let y = 0; y < el.scrollHeight; y += step) {
|
||||
el.scrollTop = y;
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
el.scrollTop = 0;
|
||||
});
|
||||
await page.waitForLoadState("networkidle").catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import {
|
||||
SECTION_MOBILITY,
|
||||
SECTION_VITALS,
|
||||
} from "@/components/insights/derived/use-dashboard-derived";
|
||||
|
||||
/**
|
||||
* A POPULATED Insights overview fixture.
|
||||
*
|
||||
* The mobile-overflow guard exists to prove that a page carrying real content
|
||||
* does not scroll sideways. Against the shared e2e account — which owns no
|
||||
* measurements — `/insights` resolves to its empty state, and an empty page
|
||||
* trivially fits any viewport. Every section below therefore gets a body:
|
||||
* populated wellness scores, a full vitals grid, and the "signals of the day"
|
||||
* card whose heading carries the long plain-language provenance caption.
|
||||
*
|
||||
* The strings are deliberately long. Prose in a heading's trailing `action`
|
||||
* slot is exactly what widened the page in the first place, and a fixture with
|
||||
* short labels would not reproduce it.
|
||||
*/
|
||||
|
||||
const NOW = "2026-01-15T08:30:00.000Z";
|
||||
|
||||
function provenance(inputs: string[]) {
|
||||
return {
|
||||
inputs,
|
||||
source: "DAY" as const,
|
||||
windowDays: 30,
|
||||
computedAt: NOW,
|
||||
};
|
||||
}
|
||||
|
||||
function coverage() {
|
||||
return {
|
||||
requiredInputs: 4,
|
||||
presentInputs: 4,
|
||||
historyDays: 90,
|
||||
missing: [] as string[],
|
||||
};
|
||||
}
|
||||
|
||||
function confidence() {
|
||||
return { score: 88, band: "high" as const };
|
||||
}
|
||||
|
||||
function deviation(
|
||||
type: string,
|
||||
value: number,
|
||||
low: number,
|
||||
high: number,
|
||||
direction: "above" | "below" | "in",
|
||||
) {
|
||||
return {
|
||||
type,
|
||||
value,
|
||||
center: (low + high) / 2,
|
||||
low,
|
||||
high,
|
||||
outside: direction !== "in",
|
||||
direction,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The "signals of the day" payload in its `fired` state — the branch that
|
||||
* renders `SectionHeading action={<CoincidentProvenance/>}`, i.e. a full
|
||||
* sentence pinned into the heading row.
|
||||
*/
|
||||
const COINCIDENT_FIRED = {
|
||||
metric: "COINCIDENT_DEVIATION",
|
||||
status: "ok" as const,
|
||||
value: {
|
||||
fired: true,
|
||||
day: "2026-01-15",
|
||||
illnessExplained: false,
|
||||
vitals: [
|
||||
deviation("RESTING_HEART_RATE", 71, 52, 62, "above"),
|
||||
deviation("HEART_RATE_VARIABILITY", 38, 45, 78, "below"),
|
||||
deviation("RESPIRATORY_RATE", 17.4, 12.5, 16.2, "above"),
|
||||
deviation("OXYGEN_SATURATION", 97, 96, 99, "in"),
|
||||
deviation("BODY_TEMPERATURE", 36.9, 36.2, 37.1, "in"),
|
||||
],
|
||||
contributing: [
|
||||
deviation("RESTING_HEART_RATE", 71, 52, 62, "above"),
|
||||
deviation("HEART_RATE_VARIABILITY", 38, 45, 78, "below"),
|
||||
deviation("RESPIRATORY_RATE", 17.4, 12.5, 16.2, "above"),
|
||||
],
|
||||
},
|
||||
coverage: coverage(),
|
||||
confidence: confidence(),
|
||||
provenance: provenance([
|
||||
"RESTING_HEART_RATE",
|
||||
"HEART_RATE_VARIABILITY",
|
||||
"RESPIRATORY_RATE",
|
||||
]),
|
||||
reason: null,
|
||||
};
|
||||
|
||||
/** A deterministic sparkline series around `center`. */
|
||||
function series(center: number, spread: number): number[] {
|
||||
return Array.from({ length: 30 }, (_, i) =>
|
||||
Number((center + Math.sin(i / 2.5) * spread).toFixed(2)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A populated `WellnessScoreValue` entry — the exact wire shape the three
|
||||
* persisted score types share (`RECOVERY_SCORE` / `STRESS_SCORE` /
|
||||
* `STRAIN_SCORE`).
|
||||
*/
|
||||
function wellnessScore(
|
||||
metric: string,
|
||||
score: number,
|
||||
band: "green" | "yellow" | "red",
|
||||
) {
|
||||
return {
|
||||
metric,
|
||||
status: "ok" as const,
|
||||
value: {
|
||||
score,
|
||||
band,
|
||||
trendDelta: 3.4,
|
||||
daysInWindow: 28,
|
||||
asOf: NOW,
|
||||
series: series(score, 6),
|
||||
},
|
||||
coverage: coverage(),
|
||||
confidence: confidence(),
|
||||
provenance: provenance(["SLEEP_DURATION", "RESTING_HEART_RATE"]),
|
||||
reason: null,
|
||||
assessment: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A populated `VitalsBaselineValue` tile — exact wire shape. */
|
||||
function vitalsBaseline(type: string, center: number, spread: number) {
|
||||
return {
|
||||
metric: "VITALS_BASELINE",
|
||||
status: "ok" as const,
|
||||
value: {
|
||||
type,
|
||||
center,
|
||||
low: Number((center - spread).toFixed(2)),
|
||||
high: Number((center + spread).toFixed(2)),
|
||||
spread,
|
||||
sampleDays: 30,
|
||||
k: 2,
|
||||
series: series(center, spread / 2),
|
||||
},
|
||||
coverage: coverage(),
|
||||
confidence: confidence(),
|
||||
provenance: provenance([type]),
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The honest "not enough history" arm. Metrics whose value shapes this fixture
|
||||
* does not reproduce exactly are served as `insufficient` rather than as an
|
||||
* invented payload — that is a real state those tiles handle (they un-mount),
|
||||
* and a wrong-shaped `ok` payload would throw in the tile instead of testing
|
||||
* anything.
|
||||
*/
|
||||
function insufficient(metric: string) {
|
||||
return {
|
||||
metric,
|
||||
status: "insufficient" as const,
|
||||
value: null,
|
||||
coverage: {
|
||||
requiredInputs: 4,
|
||||
presentInputs: 1,
|
||||
historyDays: 3,
|
||||
missing: ["SLEEP_DURATION"],
|
||||
},
|
||||
confidence: null,
|
||||
provenance: provenance([]),
|
||||
reason: "not_enough_history",
|
||||
};
|
||||
}
|
||||
|
||||
/** Center + spread per vital, in each metric's own units. */
|
||||
const VITAL_BANDS: Record<string, [number, number]> = {
|
||||
RESTING_HEART_RATE: [57, 5],
|
||||
RESPIRATORY_RATE: [14.1, 1.8],
|
||||
OXYGEN_SATURATION: [97, 1.5],
|
||||
BODY_TEMPERATURE: [36.6, 0.45],
|
||||
BLOOD_GLUCOSE: [92, 13],
|
||||
WEIGHT: [79.4, 3],
|
||||
};
|
||||
|
||||
function buildBatchMetrics(): Record<string, unknown> {
|
||||
const metrics: Record<string, unknown> = {
|
||||
RECOVERY_SCORE: wellnessScore("RECOVERY_SCORE", 66, "yellow"),
|
||||
STRESS_SCORE: wellnessScore("STRESS_SCORE", 41, "green"),
|
||||
STRAIN_SCORE: wellnessScore("STRAIN_SCORE", 72, "yellow"),
|
||||
READINESS: insufficient("READINESS"),
|
||||
SLEEP_SCORE: insufficient("SLEEP_SCORE"),
|
||||
FITNESS_AGE: insufficient("FITNESS_AGE"),
|
||||
VASCULAR_AGE_DELTA: insufficient("VASCULAR_AGE_DELTA"),
|
||||
HRV_BALANCE: insufficient("HRV_BALANCE"),
|
||||
BMI: insufficient("BMI"),
|
||||
SIX_MINUTE_WALK_BAND: insufficient("SIX_MINUTE_WALK_BAND"),
|
||||
};
|
||||
for (const type of SECTION_VITALS) {
|
||||
if (type === "HEART_RATE_VARIABILITY") continue;
|
||||
const [center, spread] = VITAL_BANDS[type] ?? [50, 5];
|
||||
metrics[`VITALS_BASELINE:${type}`] = vitalsBaseline(type, center, spread);
|
||||
}
|
||||
for (const { metric } of SECTION_MOBILITY) {
|
||||
metrics[metric] = insufficient(metric);
|
||||
}
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route-mock a populated Insights overview onto `page`.
|
||||
*
|
||||
* Only the reads that decide whether a section renders AT ALL are stubbed;
|
||||
* everything else (the i18n bundle, every layout decision, the real component
|
||||
* tree) stays authentic, so the geometry under test is the shipped geometry.
|
||||
*/
|
||||
export async function mockPopulatedInsights(page: Page): Promise<void> {
|
||||
// Without a non-empty comprehensive payload the page short-circuits to its
|
||||
// "no data yet" empty state and no section mounts at all.
|
||||
// RegExp matchers throughout, not globs: Playwright's URL glob treats `?` as
|
||||
// a single-character wildcard, so `**/api/insights/derived?*` also matches
|
||||
// `/api/insights/derived/batch?…` and the two handlers shadow each other.
|
||||
await page.route(/\/api\/insights\/comprehensive(\?|$)/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
totalMeasurements: 4821,
|
||||
moodSummary: { count: 96 },
|
||||
medications: [{ id: "med-1" }, { id: "med-2" }, { id: "med-3" }],
|
||||
},
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// The batched derived read behind the wellness strip + the vitals grid.
|
||||
await page.route(/\/api\/insights\/derived\/batch(\?|$)/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
data: { metrics: buildBatchMetrics() },
|
||||
error: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// The single-metric derived route. Only COINCIDENT_DEVIATION is stubbed —
|
||||
// it is the card whose heading carries the provenance sentence. Any other
|
||||
// metric falls through to the real handler.
|
||||
await page.route(/\/api\/insights\/derived\?/, async (route) => {
|
||||
const metric = new URL(route.request().url()).searchParams.get("metric");
|
||||
if (metric !== "COINCIDENT_DEVIATION") return route.fallback();
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: COINCIDENT_FIRED, error: null }),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -82,7 +82,20 @@ export function SectionHeading({
|
||||
</div>
|
||||
</div>
|
||||
{action ? (
|
||||
<div className="flex shrink-0 items-center gap-2">{action}</div>
|
||||
// The action slot must be able to SHRINK. It was `shrink-0`, which
|
||||
// pins a flex item at its max-content width — fine for the compact
|
||||
// control the slot was designed for, wrong for the prose every real
|
||||
// caller passes today (the provenance method caption, the
|
||||
// device-score subtitle, the sleep-quality subtitle). A sentence at
|
||||
// max-content is ~1.2-1.5k px, and because the row sits inside the
|
||||
// AuthShell `<main>` — whose `overflow-y:auto` makes `overflow-x`
|
||||
// compute to `auto` — that width became a horizontally scrollable
|
||||
// page at every phone width, growing with the locale's string length.
|
||||
// `min-w-0` lifts the flex `min-width:auto` floor so the text can wrap
|
||||
// to the row width instead. The parent's `flex-wrap` still moves the
|
||||
// slot onto its own line before anything is squeezed, so a compact
|
||||
// control keeps its size.
|
||||
<div className="flex min-w-0 items-center gap-2">{action}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user